SOLID is a set of five design rules: single responsibility, open/closed, Liskov substitution, interface segregation and dependency inversion. In Go they apply to packages, types and small interfaces rather than to class hierarchies, and implicit interfaces make most of them cheap.
S, single responsibility: a type or package has one reason to change. An OrderService that also renders PDF invoices and sends email changes for three different teams; split those out.
O, open/closed: add behaviour without editing code that works: a new type that satisfies an existing interface, or a middleware wrapper, instead of one more case in a switch on a kind field.
L, Liskov substitution: every implementation must work wherever the interface is accepted: no ErrNotSupported from a method callers rely on, no panic where the others return an error.
I, interface segregation: no client should depend on methods it does not call. In Go that is a one-to-three-method interface declared by the consumer: io.Reader, not a 14-method Storage.
D, dependency inversion: business code depends on an abstraction it owns, and the database adapter implements it: the service declares type orderRepo interface{ ... }, and main passes in the Postgres type.
GoMiddle
Architecture & Patterns in Go
SOLID in Go, Clean and hexagonal architecture, DDD building blocks, CQRS, Go-idiomatic patterns, dependency injection
Questions closed
0 / 17
- 0Review
- 2Asked often
Questions
DRY (don't repeat yourself) says each piece of knowledge lives in one place, KISS (keep it simple) says choose the simplest design that works, and YAGNI (you aren't gonna need it) says do not build for needs you only imagine. Merging duplicates hurts when the copies only look alike.
DRY is about knowledge, not text: a tax rate or a password rule written in two handlers will drift apart. Two loops that happen to look the same are not one piece of knowledge.
The wrong abstraction: two similar functions merged into one with a mode bool parameter couple callers that change for different reasons. Inline it back and let the copies differ.
"A little copying is better than a little dependency" (a Go Proverb): copy a ten-line helper rather than pull in a library or a shared common package for it.
KISS in Go: a function before an interface, a plain for loop before a generic helper, the standard library before a framework.
YAGNI: no interface with a single implementation "for later", no plugin system, no config flag nobody sets. With implicit interfaces, adding one when the second implementation arrives takes minutes.
Coupling is how much one package depends on the details of others; cohesion is how closely the code inside one package belongs together. Aim for low coupling and high cohesion: a change then stays inside one package, and each package does one job.
Coupling shows in the imports: a handler package that imports database/sql, the ORM models and three domain packages breaks when any of them changes. A feature change that touches five packages is the same symptom.
A wide exported surface: exported fields and helpers let other packages depend on details. Keep names unexported until something outside needs them, and the compiler enforces the boundary.
Low cohesion shows in the name: utils, common or helpers has no single job, so it collects unrelated code and everything imports it.
The fixes: group code by domain (order, billing), let the consumer declare a small interface instead of importing a concrete type, and keep code that changes together in one package.
A quick check: go list -f '{{join .Imports "\n"}}' ./internal/order prints what a package really depends on.
It breaks the Liskov substitution principle: code written against Cache expects Set to store the value, and ReadOnlyCache compiles but breaks that promise at run time. Fix it with interface segregation: split reading from writing, so a read-only type never claims to have Set.
Why the compiler allows it: Go checks only method names and signatures. The contract (after a successful Set, Get finds the value) lives in the docs and in the callers' heads.
How it fails: a write-through service calls Set after every database load and gets ErrReadOnly each time, so it logs noise or fails requests. Then callers start writing if _, ok := c.(*ReadOnlyCache); ok, the classic sign of an LSP violation.
The fix: a Getter interface with Get only, and a Cache that embeds Getter and adds Set. Code that only reads accepts a Getter, and ReadOnlyCache implements just Get.
The compiler now does the checking: passing a *ReadOnlyCache where a Cache is required is a build error instead of a production log line. The standard library splits io.Reader, io.Writer and io.ReadWriter the same way.
LSP covers behaviour too: the same error meanings, no stricter inputs (a Get that rejects keys the others accept), no panics where the others return errors.
Dependency inversion is a design rule: business code depends on an interface it owns. Dependency injection is a technique: a component gets its dependencies passed in. Inversion of control is the wider idea that something else drives your code. A hand-wired main does DI without a container.
Dependency inversion (the D in SOLID): without it, order imports postgres. With it, order declares type Repository interface{ ... } and postgres imports order to implement it: the import arrow points from the detail to the policy, against the direction of the calls.
Dependency injection: NewService(repo Repository) instead of calling postgres.NewRepo() inside the service. Injecting a concrete *postgres.Repo is still DI, but it inverts nothing.
Inversion of control: the library calls your code. http.Server calls your ServeHTTP, sort.Sort calls your Less, go test calls your TestX. DI is one form of it: the component no longer controls how its dependencies are made.
**The hand-wired main: the one place that knows every concrete type, builds each one and passes it in. That is DI, and it gives you DIP as long as each interface is declared by the package that uses it.
A DI container** (fx, or Spring in Java) applies IoC to the wiring itself, deciding what to build and in which order. It adds nothing to DIP; it only automates main.
Pass dependencies in explicitly, as constructor parameters stored in struct fields, and wire the whole graph by hand in main. Most services need no framework, and the compiler already checks hand-written wiring.
Constructor injection: NewOrderService(repo orderRepo). The service declares small interfaces for what it needs, and tests pass fakes.
No globals: a package-level var db set in init() hides the dependency from every signature and makes parallel tests share one value.
Composition root: main, or a run function it calls, opens the database, builds repositories, services and handlers, and starts the server. A hundred lines of plain calls is easier to follow than a container.
**google/wire generates that same wiring code from provider functions at build time: no reflection, and a missing dependency is a build error. The repository was archived in 2025, so treat it as frozen rather than evolving.
uber-go/fx** builds the graph at run time with reflection and adds start and stop hooks. Wiring errors appear at startup instead of compile time; it pays off when many services share one application skeleton.
Most patterns survive but shrink to a language feature: Strategy is an interface or a function value, Decorator is middleware, Builder becomes functional options, Singleton is sync.OnceValue, Iterator is iter.Seq. Patterns built on inheritance have nothing to do in Go.
Factory: a NewT constructor; one that returns an interface fits when the concrete type depends on input, the way net.Dial returns a net.Conn that is a *net.TCPConn or a *net.UnixConn depending on the network.
Builder: functional options or a config struct. Method chains show up mainly in query builders such as squirrel: sq.Select("id").From("orders").
Singleton: sync.OnceValue for a lazy one, a package-level var for an eager one, and usually neither: a value built in main and passed down can be replaced in a test.
Decorator and chain of responsibility: func(http.Handler) http.Handler middleware and gRPC interceptors.
Adapter: http.HandlerFunc turns a plain function into an http.Handler; a small struct makes a third-party client fit your interface.
Observer: a channel, or a slice of callbacks for synchronous hooks; between services, a message broker.
Gone: Template Method and abstract base classes need inheritance, so pass a function or an interface instead; Visitor mostly turns into a type switch.
In an interview, add one you really used and why, for example a repository behind an interface so the service tests run against a fake.
A strategy is a value the caller passes in to pick the behaviour: a one-method interface or simply a function. Take a function when the behaviour is one stateless call; take an interface when implementations carry state or configuration, need a name in logs, or may grow a second method.
Functions are the lighter tool: slices.SortFunc(xs, cmp) and strings.Map(f, s) take a function, so the caller writes a closure inline and needs no named type. A test passes a one-line closure instead of a fake.
Interfaces fit stateful strategies: CouponPricer holds a coupon table, and an implementation that calls a pricing service holds a client and a timeout.
Offering both: declare type PricerFunc func(Order) int64 with a Discount method that calls the function itself. A plain function then satisfies Pricer, exactly the way http.HandlerFunc satisfies http.Handler.
Why a method on a func type works: any named type can have methods, a function type included; the method just calls its receiver.
Choosing at run time: build a map[string]Pricer in main and look the strategy up by name. A new strategy is then a new type and a map entry, not another case in business code (the open/closed principle).
The decorator embeds UserRepo, so UpdateEmail is promoted from the inner repository: writes go straight to the database and never touch the cache. Get keeps serving the cached User until its TTL runs out.
Why: embedding an interface forwards every method the struct does not define itself. The author overrode Get and forgot that writes concern the cache too; the compiler has no reason to complain.
Why the tests missed it: they checked a hit and a miss on Get, but never read a user back through the decorator after changing it.
The fix: implement UpdateEmail on the decorator: write to the database, then delete the cached entry. Delete rather than overwrite, so the next Get loads the committed row, and delete even on error, because a write that timed out may still have committed.
Prefer a named field: with next UserRepo the compiler demands every method, so a Delete added to UserRepo next quarter fails the build here until someone decides what it does to the cache. Embedding is fine when passing the other methods through unchanged is always right, as in a wrapper around http.ResponseWriter that only records the status code.
A narrow race remains: a Get that read the old row just before the update can put it back into the cache after the delete; the TTL bounds how long that copy lives.
Both put the business rules in the centre and everything technical (HTTP, SQL, Kafka, third-party APIs) at the edge, under one rule: source code dependencies point only inward. The core declares ports (interfaces), adapters implement them, and main plugs them together.
Hexagonal: the core offers driving ports (what the outside may ask for: PlaceOrder) and declares driven ports (what it needs: OrderRepository, PaymentGateway). HTTP handlers and Kafka consumers drive it; Postgres and the payment client are driven adapters.
Clean Architecture: the same idea drawn as rings: entities, use cases, interface adapters, frameworks. The ring names matter less than the rule.
The dependency rule in Go: internal/order imports no database/sql, no net/http, no generated protobuf or ORM types; internal/postgres imports order, never the reverse. The core's import list shows it in seconds.
What it buys: use cases are tested with in-memory fakes in milliseconds, and a gRPC API next to REST or a new store touches only adapters.
When it is overkill: a CRUD service whose domain is its tables. Mapping every field from a request DTO to a domain struct to a row and back buys nothing there.
Not a folder template: entity/, usecase/ and repository/ packages copied from Java spread one feature across the tree. Package by feature, keep SQL and HTTP out of the feature's core, and add ports when a second adapter or real business rules appear.
It leaks when its signatures carry storage types or storage concepts, because then every caller depends on the database through the interface that was meant to hide it. A repository should take and return domain types, report domain errors, and offer queries named by intent.
Storage types: *sql.Rows, pgx.Row, sql.NullString fields or a GORM model with gorm.Model embedded make the service import the driver and deal with rows and NULLs itself, and every fake has to fake them too.
SQL in arguments: Find(ctx, where string, args ...any) moves the query into the service. Name the intent instead, ListUnpaidBefore(ctx, cutoff, ...), and keep the SQL in the adapter.
Driver errors: returning sql.ErrNoRows or a *pgconn.PgError makes callers match driver errors. Translate them in the adapter into ErrNotFound, ErrDuplicate and the like.
Storage mechanics: a *sql.Tx parameter is the database again, so keep transaction handling behind one helper. For paging, an opaque cursor lets the adapter move from OFFSET to keyset pagination without touching callers.
Generic CRUD: Repository[T] with Update(ctx, T) exposes the table, not the domain: any caller can write any field and skip the rules the aggregate enforces.
The test: could an in-memory map implement the interface in 20 lines without importing a database package? If not, it leaks.
Declare a port in your own terms (payment.Gateway with your Charge type and ErrDeclined) and implement it in one adapter package, the only one that imports the SDK and translates its types, statuses and errors. That adapter is the anti-corruption layer: the provider's model stops there.
What leaks otherwise: a stripe.PaymentIntent in a service signature, provider status strings compared in business code, checks for the S3 SDK's *types.NoSuchKey in handlers. Every major version of the SDK then touches the whole codebase.
Translate everything that crosses: amounts (cents or decimal strings), currency codes, statuses ("requires_action" becomes your own NeedsAuth), and errors: a decline code becomes payment.ErrDeclined, a 5xx or a timeout becomes a retryable error.
Keep the provider's id, not its objects: store its charge id as an opaque string for refunds and reconciliation.
What it buys: a second provider or a switch is a new adapter; the domain is tested against a fake Gateway, the adapter against the sandbox or an httptest.Server; the provider's quirks (idempotency keys, retries, webhook formats) live in one package.
What it costs: mapping code, and the temptation to pass the SDK type through "just this once". When the domain needs a feature only this provider has, add it to the port explicitly.
An entity has an identity that survives changes (an order with its id), a value object is defined only by its values and never changes (Money), and an aggregate is a cluster of them changed only through its root, which guards the invariants. In Go, unexported fields and methods do the guarding.
Entity: compared by id, not by fields: two orders with the same lines are still two orders.
Value object: a small comparable struct: == compares it field by field, and value receivers make operations such as Add return a new value instead of changing the old one.
Aggregate: an Order with its lines. The root's methods enforce rules such as "no lines after payment" and "one currency per order"; nothing outside holds a pointer into the lines, and one transaction changes one aggregate.
Exported fields defeat it: any package can write o.Paid = true or append to o.Lines. Keep the fields unexported, expose behaviour (AddLine, Pay) and getters, and return copies of slices with slices.Clone.
Construction: New sets up a valid order. Outside code can still write order.Order{}, so keep the methods safe on the zero value.
The price: the Postgres adapter in another package cannot scan into unexported fields, so the domain exports a loading constructor that rebuilds an Order from stored values, often called Rehydrate or FromSnapshot.
Use an interface with an unexported marker method: only its own package can declare that method, so the set is closed. Each variant is a struct with its own data, code branches with a type switch, and a linter (gochecksumtype, or exhaustive for iota enums) fails the build on a missing case.
Why the marker closes the set: no other package can declare a method that matches an unexported isMethod, so the variants are the types you wrote, each with its own fields instead of one struct with six optional ones. The one loophole is a struct elsewhere that embeds Card and gets isMethod promoted; the final return catches it.
Exhaustiveness is not built in: the compiler does not check that a type switch covers every variant. Mark the interface //sumtype:decl and gochecksumtype in golangci-lint reports a switch that misses one; for iota constants, exhaustive does the same for switch statements.
Mind the default case: a default clause satisfies gochecksumtype unless it panics, so it hides a missing variant. Leave it out and handle what is left, only a nil interface, after the switch.
Or put the behaviour on the variants: a question every variant must answer, such as Fee(), belongs in the interface, and then the compiler checks each variant. Type switches suit logic outside the types, like rendering or mapping to protobuf.
Order states carry no data, so an iota enum plus exhaustive is enough. Interface variants in JSON need a discriminator field ("type": "card") and a hand-written UnmarshalJSON.
A bounded context is the boundary inside which one model and its terms have one meaning: a Product in the catalogue (description, photos) is not the Product in the warehouse (weight, shelf) or in billing (price, tax). Each context gets its own package or service, its own types and its own data.
Finding the boundaries
The same word meaning different things to different teams, or different rules for the same thing, marks a seam. Team structure usually points at the same lines (Conway's law).
A good boundary keeps most changes inside it: a new pricing rule does not touch warehouse code.
Two contexts that change together and call each other on every request are one context, split too early.
In a Go codebase
Start as a modular monolith: internal/catalog, internal/inventory, internal/billing, each with its own types and its own tables or schema. A shared models package is one model in disguise.
Contexts talk through a narrow API (an exported service type or events), never through each other's tables or internal structs. depguard in golangci-lint can forbid the imports that would cross the line.
Each context keeps its own copy of what it needs: billing stores the price it charged, not a reference to a catalogue row that may change tomorrow.
Between contexts
A consumer translates another context's events into its own model, the same anti-corruption idea as with a third-party SDK. A shared kernel (a few types both sides own, such as Money) is allowed, but it turns every change into a negotiation, so keep it tiny.
With boundaries like these, moving a context into its own service later is a transport change. A service that spans two contexts, or one context spread over three services, is how a distributed monolith starts.
CQRS (command query responsibility segregation) splits the code that changes state from the code that reads it: commands go through the domain model, queries read a model shaped for the screen. A separate read model pays off when reads and writes differ sharply, and it costs eventual consistency.
Two levels
In the code: command handlers load the aggregate and enforce the rules; query functions skip it and run a plain SELECT into a DTO. Same database, no consistency cost, and often all a Go service needs.
In the storage: a denormalised table, a search index (OpenSearch, Elasticsearch) or a Redis view, updated from events or CDC after the write commits.
When the read model is worth it
Reads outnumber writes by orders of magnitude and need joins across aggregates, full-text search or precomputed totals; otherwise the dashboard query competes with writes for the same tables.
The write model is rich in invariants, and pushing list screens through it would load whole aggregates to show three fields.
What it costs
Lag: a user saves and the list does not show it yet. Return the new state from the command, read the primary for that user for a few seconds, or show the item as "processing".
Projections: they must be idempotent, apply events in order per aggregate (check a version number), and be rebuildable from scratch after a bug or a schema change.
Operations: two models, more infrastructure, and projection lag to monitor and alert on. For plain CRUD it is overkill.
In event sourcing the append-only log of domain events is the source of truth, and current state is computed by replaying it. With state plus an outbox the row is the truth and events are notifications published about changes to it: you can drop or reshape old events without losing any data.
How it works
Each aggregate is a stream: Placed, LineAdded, Paid. Loading folds the stream into state; saving appends new events at the next version, and a unique (stream_id, version) key makes a concurrent writer fail: optimistic concurrency without locks.
Read models are projections of the same events, so event sourcing nearly always comes with CQRS.
It gives an audit trail by construction, the state at any past moment, and new projections built from history.
Compared with state plus an outbox
With an outbox the table can change shape freely, queries are plain SQL, and nothing is rebuilt from events.
With event sourcing the events are the database: current state needs a projection before you can query it, and a wrong event cannot be fixed with an UPDATE.
What makes it hard
Schema evolution: events are kept forever, so every old version must still load, through upcasters that convert v1 events on read or through versioned event types.
Long streams: replaying 100,000 events per load is slow; snapshots every N events speed it up and add one more thing to keep correct.
Mistakes and erasure: a wrong event is corrected by a compensating event. Deleting personal data from an immutable log needs crypto-shredding: encrypt per user and delete the key.
Projections: rebuilding one can take hours, and every consumer must be idempotent and keep the order within a stream.