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 main thin — only wiring and startup.
  • Avoid the utils or helpers package; 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.Context as 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

PatternProblem it solves
Clean / Hexagonal ArchitectureDecouple domain from infrastructure
Dependency InjectionTestability, replaceability
RepositoryHide storage implementation details
Functional OptionsOptional parameters without breaking callers
MiddlewareCross-cutting concerns without modifying handlers
Worker PoolBound concurrency, control resource use
context.ContextCancellation, deadlines across call chains