Designing Flexible Go Repositories with DDD: Interfaces, Implementations, and Snapshots
This article explains how to persist domain entities using DDD‑driven repositories in Go, covering value objects vs. entities, repository interface design, method naming conventions, single‑ and multi‑table implementations, and snapshot‑based change detection to handle evolving storage requirements.
Value Objects vs. Entities
In Domain‑Driven Design (DDD) a value object has no unique identifier and is immutable; it is used as an attribute of an entity. An entity possesses a globally unique identity and can contain rich domain behaviour. Because value objects lack identity and concurrency concerns, they are simpler to model and maintain.
Repository vs. DAO
A repository (or resource library) is an abstraction that persists aggregate roots. It hides the storage technology and works with domain concepts only. A DAO (Data Access Object) directly manipulates a specific database schema and is tightly coupled to the underlying persistence mechanism. Repositories therefore belong to the domain layer, while DAOs belong to the infrastructure layer.
Repository Interface Design
Repository interfaces should be defined in the domain layer but placed in a separate package from domain entities to avoid circular dependencies. All method parameters and return types must be domain structs or primitive Go types. A minimal generic repository interface typically includes: Save(ctx context.Context, agg AggregateRoot) error – upserts the aggregate. Find(ctx context.Context, id ID) (AggregateRoot, error) – returns nil when not found.
FindNonNil(ctx context.Context, id ID) (AggregateRoot, error)– returns a NotFound error instead of nil. NextIdentity(ctx context.Context) (ID, error) – generates a new unique identifier for an entity.
Method Naming Guidelines
Because a repository should be storage‑agnostic, avoid technical verbs such as insert, update, select, or delete. Prefer generic verbs like Save, Find, and Remove.
General Repository Interface Example
The diagram below illustrates a typical repository contract.
Single‑Table Scenario
Assume an Order aggregate maps one‑to‑one to an orders table. The domain defines OrderRepository; the infrastructure implements it as OrderDBRepository in order_repo_impl.go. The Data Access Layer (DAL) contains raw SQL, e.g. an Upsert statement.
Key behaviours: Save performs an upsert, handling both insert and update. Find returns nil when the row does not exist. FindNonNil converts a missing row into a NotFound error, simplifying service‑layer checks.
Multi‑Table Scenario
When an Order contains many Item s stored in a separate order_items table, two naïve approaches exist:
Serialize the items slice as JSON and store it in an items column of orders. This simplifies persistence but makes item‑level queries inefficient.
Always update the whole Order record regardless of which part changed, leading to unnecessary write traffic.
A more robust solution introduces a snapshot‑based change tracker that records the aggregate’s state when it is loaded and diffs it before persisting.
Snapshot‑Based Change Tracking
Each aggregate holds a hidden snapshot of its original state. The Save method calls DetectChanges(), which returns an OrderDiff describing added, updated, or removed Item s. The repository then issues only the required INSERT/UPDATE/DELETE statements.
When an aggregate is retrieved, the repository invokes Attach() to store the snapshot. Subsequent modifications do not affect the snapshot, ensuring a reliable diff.
Infrastructure Layer Organization
Typical Go package layout:
domain/
├─ model/ // domain structs (Order, Item, ID, etc.)
└─ repo/ // repository interfaces
infra/
├─ persistence/ // concrete repository implementations
│ ├─ order_repo_impl.go
│ └─ dal/ // raw SQL helpers (Upsert, Select, etc.)
└─ converter/ // optional mapping between domain and persistence modelsThe converter package is useful when the persistence model (PO) diverges from the domain model. If the structures match, the domain model can be used directly, but developers must avoid mutating exported fields outside the domain layer.
Multi‑Table Implementation Details
For an Order with a one‑to‑many Item relationship:
Load the Order and its Item s via a join or separate queries.
After loading, call order.Attach() to capture the snapshot.
On Save, compute diff := order.DetectChanges(). The diff contains three slices: AddedItems, UpdatedItems, RemovedItemIDs.
Execute only the necessary SQL statements: bulk INSERT for AddedItems, UPDATE for UpdatedItems, and DELETE for RemovedItemIDs.
This approach eliminates the two naïve strategies described earlier and keeps database traffic proportional to actual changes.
Key Takeaways
Define repository contracts in the domain layer using generic verbs ( Save, Find, Remove).
Implement repositories in the infrastructure layer, keeping domain code free of persistence details.
Use an upsert‑based Save for simple aggregates that map 1:1 to a table.
For aggregates spanning multiple tables, store a snapshot on load and diff it on save to issue minimal SQL operations.
Optional converter packages bridge mismatched domain and persistence models.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
ITPUB
Official ITPUB account sharing technical insights, community news, and exciting events.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
