Go Interview Prep #8: Architectural Patterns
Go’s simplicity makes it easy to write code, but architecture questions — how to structure a codebase, how to decouple components, how to handle dependencies — consistently come up in senior-level interviews.
Clean / Hexagonal Architecture
The idea is to separate your domain logic from infrastructure (database, HTTP, message queues) so that the core is independently testable and infrastructure is pluggable.
Typical layer layout:
/internal
/domain ← pure business logic and interfaces
/service ← use-case orchestration
/repo ← database implementations
/handler ← HTTP / gRPC adapters
The domain layer defines interfaces; the outer layers implement them. Dependency arrows always point inward — handler depends on service, service depends on domain, nothing in domain knows about HTTP or SQL.
In interviews, emphasize dependency direction over folder names. The folders above are a common Go layout, not a standard.
Dependency Injection
Rather than constructing dependencies inside a function, accept them as parameters. This makes the component testable with fakes or mocks.
type UserService struct {
repo UserRepository
log Logger
}
func NewUserService(repo UserRepository, log Logger) *UserService {
return &UserService{repo: repo, log: log}
}
In Go, dependency injection is usually done manually (pass dependencies through constructors). For larger codebases, tools like wire generate the wiring code. Avoid service-locator globals and init() that reach for databases — they hide dependencies and hurt tests.
Repository Pattern
Defines a storage interface in the domain (or service) layer, keeping SQL/NoSQL details out of business logic:
type UserRepository interface {
GetByID(ctx context.Context, id int) (*User, error)
Save(ctx context.Context, u *User) error
}
Production code uses a Postgres implementation; tests use an in-memory fake. The business logic never changes.
Keep interfaces small and defined by the consumer. A 20-method “god repository” is a smell — split by use case if needed.
Functional Options Pattern
A clean way to configure structs with optional parameters, avoiding a combinatorial explosion of constructors:
type Server struct {
addr string
timeout time.Duration
}
type Option func(*Server)
func WithTimeout(d time.Duration) Option {
return func(s *Server) { s.timeout = d }
}
func NewServer(addr string, opts ...Option) *Server {
s := &Server{addr: addr, timeout: 30 * time.Second}
for _, o := range opts {
o(s)
}
return s
}
// Usage
srv := NewServer(":8080", WithTimeout(5*time.Second))
Variants return (Option, error) when validation can fail, or apply options that return an error from NewServer.
Middleware Pattern
Wraps a handler with cross-cutting concerns (auth, logging, rate-limiting) without modifying the handler:
func Logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Println(r.Method, r.URL.Path, time.Since(start))
})
}
Middlewares are composable and stackable (order matters — outermost runs first on the way in):
http.Handle("/", Logger(Auth(myHandler)))
The same idea applies beyond HTTP: gRPC interceptors, decorator-style wrappers around service methods.
Worker Pool Pattern
Limits the number of concurrent goroutines to control resource use:
func workerPool(ctx context.Context, jobs <-chan Job, numWorkers int) error {
g, ctx := errgroup.WithContext(ctx)
for range numWorkers {
g.Go(func() error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case j, ok := <-jobs:
if !ok {
return nil
}
if err := process(ctx, j); err != nil {
return err
}
}
}
})
}
return g.Wait()
}
Close the jobs channel when all work has been submitted; workers exit when the channel is drained (or when ctx is cancelled). Bound concurrency instead of go per item on unbounded input.
Package structure advice
- Keep
mainthin — only wiring and startup. - Avoid the
utilsorhelperspackage; they become grab-bags. Name packages by what they provide, not by what they are. - Use
internal/for code that should not be imported by other modules. - Prefer flat package hierarchies over deep nesting.
- Accept interfaces, return concrete types.
- Pass
context.Contextas the first parameter to any function that does I/O, waits, or should honor cancellation.
Common interview questions
Q: How do you avoid circular imports?
Define shared types and interfaces in a package that both sides can import without importing each other (domain, or the consumer’s package). Or invert the dependency: the higher-level package owns the interface; the lower-level package implements it.
Q: When would you use the functional options pattern vs a config struct?
Functional options are better when you want to grow the options list over time without breaking callers, especially for public libraries. A plain config struct is simpler for internal APIs where you control all callers. Hybrid: required fields as parameters, optional as options or a config struct.
Q: How do you test a handler that depends on a database?
Define a Repository (or port) interface. Implement a fake in test files. Inject the fake into the service/handler. No real database needed for unit tests. Use integration tests with a real DB (or testcontainers) for the repository implementation itself.
Q: What is the purpose of the context package in service design?context.Context carries deadlines, cancellations, and (sparingly) request-scoped values across API boundaries and goroutines. Pass it as the first parameter to every function that does I/O or may need to be cancelled. Do not store contexts inside structs that outlive a request; do not abuse WithValue as a DI bag.
Q: How do you structure a larger Go service?
Start modular monolith: clear packages, interfaces at boundaries, internal/ for private code. Split into multiple modules/services only when team or scaling needs justify the operational cost. Interviewers prefer pragmatic modularity over premature microservices.
Quick reference
| Pattern | Problem it solves |
|---|---|
| Clean / Hexagonal Architecture | Decouple domain from infrastructure |
| Dependency Injection | Testability, replaceability |
| Repository | Hide storage implementation details |
| Functional Options | Optional parameters without breaking callers |
| Middleware | Cross-cutting concerns without modifying handlers |
| Worker Pool | Bound concurrency, control resource use |
context.Context | Cancellation, deadlines across call chains |
