17 KiB
Architecture Rules (ARCH-01..08)
ARCH-01: Standard Directory Layout (CRITICAL)
Every service MUST follow this directory structure:
<service>/
├── domain/<aggregate>/ # Pure business logic, entities, value objects, repository interfaces
├── app/ # Application struct (app.go) with Commands + Queries
│ ├── command/ # Write use cases (command handlers)
│ └── query/ # Read use cases (query handlers + read model interfaces)
├── ports/ # Inbound adapters: HTTP handlers, gRPC servers, CLI
├── adapters/ # Outbound adapters: repository implementations, external clients
└── service/ # Composition root: wires all dependencies together
Check procedure:
- Glob for these directories relative to the service root
- Flag any missing standard directories
- Flag any non-standard directories at the same level (e.g.,
controllers/,models/,handlers/) - Multiple aggregates can exist under
domain/as sub-packages (e.g.,domain/hour/,domain/training/)
Reference (wild-workouts):
internal/trainer/
├── domain/hour/
├── app/
│ ├── command/
│ └── query/
├── ports/
├── adapters/
└── service/
ARCH-02: Dependency Direction (CRITICAL)
Dependencies MUST flow inward only: ports/adapters → app → domain
The domain layer MUST NOT import from:
app/,app/command/,app/query/ports/adapters/- Any external infrastructure package (database drivers, HTTP frameworks, etc.)
The app layer MUST NOT import from:
ports/adapters/
Check procedure:
- For every
.gofile indomain/, scan import statements - Flag any import that references
app/,ports/,adapters/, or the service's own non-domain packages - For every
.gofile inapp/, scan imports forports/oradapters/ - Domain MAY import standard library and pure utility packages
Allowed domain imports:
- Standard library (
context,time,errors,fmt,strings, etc.) - Pure value libraries (e.g.,
github.com/google/uuid) - NOT: database drivers, HTTP routers, gRPC, logging libraries
ARCH-03: Composition Root Isolation (CRITICAL)
All dependency wiring MUST happen exclusively in service/. The composition root is the only place that knows about concrete adapter types, infrastructure clients, and how dependencies connect.
main.go MUST only:
- Initialize cross-cutting concerns (logging)
- Call
service.NewApplication() - Wire ports (pass
app.Applicationto port constructors) - Start the server
main.go MUST NOT import adapters/, create infrastructure clients, or instantiate command/query handlers directly.
Check procedure:
- Scan
main.goimports — flag any reference toadapters/, database drivers, or external service clients - Scan all files outside
service/— flag any call to adapter constructors (e.g.,adapters.New*) - Verify
service/returnsapp.Application
Correct:
// main.go — only knows about service and ports
func main() {
logs.Init()
ctx := context.Background()
app, cleanup := service.NewApplication(ctx)
defer cleanup()
server.RunHTTPServer(func(router chi.Router) http.Handler {
return ports.HandlerFromMux(ports.NewHttpServer(app), router)
})
}
Wrong:
// main.go — VIOLATION: wiring infrastructure directly
func main() {
client, _ := firestore.NewClient(ctx, os.Getenv("GCP_PROJECT")) // VIOLATION
repo := adapters.NewFirestoreRepository(client) // VIOLATION
handler := command.NewScheduleTrainingHandler(repo, logger, mc) // VIOLATION
// ...
}
ARCH-04: Dual Constructor Pattern for Testability (WARNING)
The composition root MUST provide two constructors sharing a single private wiring function:
NewApplication(ctx) (app.Application, func())— production constructor, creates real infrastructureNewComponentTestApplication(ctx) app.Application— test constructor, injects mocks/stubs
Both MUST delegate to a private newApplication(...) that accepts dependencies as interfaces, so the real vs test paths only differ in what they pass in.
This ensures:
- Test mocks never leak into production wiring
- All wiring logic is shared — no drift between prod and test setups
- The private function signature documents the full set of external dependencies
Check procedure:
- Look for exported
NewApplicationandNewComponentTestApplicationinservice/ - Verify both call the same unexported function
- The unexported function MUST accept dependencies as interfaces, not concrete types
Correct:
// service/service.go
func NewApplication(ctx context.Context) (app.Application, func()) {
trainerClient, closeTrainer, err := client.NewTrainerClient()
if err != nil { panic(err) }
trainerService := adapters.NewTrainerGrpc(trainerClient)
return newApplication(ctx, trainerService),
func() { _ = closeTrainer() }
}
func NewComponentTestApplication(ctx context.Context) app.Application {
return newApplication(ctx, TrainerServiceMock{})
}
func newApplication(ctx context.Context, trainerService command.TrainerService) app.Application {
// shared wiring logic — accepts interfaces, not concrete types
repo := adapters.NewFirestoreRepository(client)
return app.Application{ /* ... */ }
}
Wrong:
// VIOLATION: separate wiring paths, no shared private function
func NewApplication(ctx context.Context) app.Application {
repo := adapters.NewFirestoreRepository(client)
return app.Application{
Commands: app.Commands{
ScheduleTraining: command.NewScheduleTrainingHandler(repo, logger, mc),
},
}
}
func NewTestApplication() app.Application {
repo := NewMockRepo() // VIOLATION: duplicated wiring, can drift
return app.Application{
Commands: app.Commands{
ScheduleTraining: command.NewScheduleTrainingHandler(repo, logger, mc),
},
}
}
ARCH-05: Cleanup Function for Resource Lifecycle (WARNING)
When the composition root creates resources that require cleanup (connections, clients, subscriptions), NewApplication MUST return a cleanup function alongside the application. The caller owns the lifecycle via defer.
This ensures:
- Resources are released even on panic
main.godoesn't need to know what to clean up — just that it must- Adding new infrastructure only changes
service/, notmain.go
Check procedure:
- If
NewApplicationcreates closeable resources (clients, connections), it MUST returnfunc() main.goMUST calldefer cleanup()immediately after receiving it- The cleanup function MUST NOT be ignored (assigned to
_)
Correct:
// service/service.go
func NewApplication(ctx context.Context) (app.Application, func()) {
trainerClient, closeTrainer, err := client.NewTrainerClient()
if err != nil { panic(err) }
usersClient, closeUsers, err := client.NewUsersClient()
if err != nil { panic(err) }
return newApplication(ctx, adapters.NewTrainerGrpc(trainerClient), adapters.NewUsersGrpc(usersClient)),
func() {
_ = closeTrainer()
_ = closeUsers()
}
}
// main.go
app, cleanup := service.NewApplication(ctx)
defer cleanup()
Wrong:
// VIOLATION: caller must know internals to clean up
func NewApplication(ctx context.Context) (app.Application, *firestore.Client, *grpc.ClientConn) {
// ...
}
// VIOLATION: cleanup responsibility leaks into main
app, fsClient, conn := service.NewApplication(ctx)
defer fsClient.Close() // main.go shouldn't know about Firestore
defer conn.Close() // main.go shouldn't know about gRPC
ARCH-06: Server Startup via Callback (WARNING)
Server startup MUST be delegated to a shared server.Run*Server() function. main.go provides only the application handler via a callback. It MUST NOT configure server internals: middleware, routing, listening address, or transport-level concerns.
This ensures:
- Middleware stack (auth, logging, recovery, CORS, security headers) is consistent across all services
- Adding or changing middleware is a single change, not per-service
main.goremains a thin orchestrator: init → wire app → provide handler → run
Check procedure:
main.goMUST call a sharedRun*Server()function as the final blocking call- The callback passed to
Run*Server()MUST only construct the handler from port constructors — no middleware setup, no router configuration, no listener creation main.goMUST NOT import server infrastructure packages (e.g.,net/http.ListenAndServe,net.Listen, middleware libraries)
Correct:
// main.go — provides handler, delegates everything else
func main() {
logs.Init()
ctx := context.Background()
app, cleanup := service.NewApplication(ctx)
defer cleanup()
server.RunHTTPServer(func(router chi.Router) http.Handler {
return ports.HandlerFromMux(ports.NewHttpServer(app), router)
})
}
Wrong:
// VIOLATION: main.go configures server internals
func main() {
app, cleanup := service.NewApplication(ctx)
defer cleanup()
router := chi.NewRouter()
router.Use(middleware.Logger) // VIOLATION: middleware in main
router.Use(middleware.Recoverer) // VIOLATION: middleware in main
router.Mount("/api", ports.NewHttpServer(app))
http.ListenAndServe(":8080", router) // VIOLATION: listening in main
}
ARCH-07: Composition Root Must Not Own Server Lifecycle (CRITICAL)
The service/ package wires dependencies and returns app.Application. It MUST NOT create transport servers, bind to network ports, handle OS signals, or manage graceful shutdown. Server lifecycle is a separate concern that belongs in a shared server package or the entry point.
service/ MUST NOT:
- Create transport servers (
grpc.NewServer(),http.Server{},message.NewRouter()) - Bind to network ports (
net.Listen()) - Handle OS signals (
signal.NotifyContext(),signal.Notify()) - Manage graceful shutdown (
GracefulStop(),router.Close()) - Import port packages (
ports/grpc,ports/amqp,ports/http)
service/ MUST only:
- Create infrastructure clients and adapters
- Wire command/query handlers with dependencies
- Return
app.Application(and optionally a cleanup function)
Check procedure:
- Scan all files in
service/for imports ofnet,os/signal,syscall, transport packages, orports/ - Flag any function in
service/that accepts or creates a server, listener, or router - A file named
server.goinservice/is a strong signal of violation
Correct:
// service/service.go — only wires the application
func NewApplication(ctx context.Context, cfg *config.Config) (app.Application, func()) {
repo := adapters.NewFirestoreRepository(client)
syncer := tokensync.NewSyncer(fetchers, syncRepo, progressTracker)
return newApplication(repo, syncer),
func() { _ = client.Close() }
}
// Server lifecycle lives elsewhere (shared server package or entry point)
Wrong:
// service/server.go — VIOLATION: server lifecycle in composition root
func RunServer(application app.Application, cfg *config.Config) error {
ctx, stop := signal.NotifyContext(context.Background(), ...) // VIOLATION: signal handling
defer stop()
grpcServer := grpc.NewServer() // VIOLATION: transport server
pb.RegisterCommandsServer(grpcServer, ports.NewServer(app)) // VIOLATION: imports ports/
lis, _ := net.Listen("tcp", fmt.Sprintf(":%s", cfg.Port)) // VIOLATION: network binding
go grpcServer.Serve(lis) // VIOLATION: server lifecycle
<-ctx.Done()
grpcServer.GracefulStop() // VIOLATION: shutdown management
return nil
}
ARCH-08: Unified Server with Named Components and OnShutdown (WARNING)
When a project has multiple transports (gRPC, HTTP, AMQP/Watermill), the shared server package SHOULD provide a single server.New(...).Run(ctx) with functional options per transport and an explicit OnShutdown that declares the shutdown sequence.
Why explicit shutdown ordering matters
Different services have different dependency graphs between transports:
- A consumer that calls gRPC must stop consuming before gRPC clients close
- An HTTP API that publishes events must drain HTTP before the publisher closes
- Two independent ingress points (HTTP + gRPC) can shut down in parallel
Implicit ordering (LIFO based on registration) is fragile — reordering lines silently changes shutdown behavior. OnShutdown makes the sequence a readable, reviewable declaration.
Core types
// server/server.go
type Server struct {
components map[string]component
startOrder []string
shutdownSteps []ShutdownStep
}
type component struct {
name string
start func(ctx context.Context) error
stop func(ctx context.Context) error
}
type Option func(*Server)
type ShutdownStep struct {
componentNames []string
fn func(ctx context.Context) error
}
API
// Stop creates a step that stops named components.
// Multiple names = parallel shutdown within the step.
func Stop(names ...string) ShutdownStep
// StopFunc creates a step that runs an arbitrary cleanup function.
func StopFunc(fn func()) ShutdownStep
// StopFuncWithErr creates a step with error return.
func StopFuncWithErr(fn func(ctx context.Context) error) ShutdownStep
// OnShutdown declares the shutdown sequence.
// Steps execute top-to-bottom. Each step completes before the next starts.
// Components not mentioned stop last (with a warning log).
func OnShutdown(steps ...ShutdownStep) Option
Shutdown execution
- Steps execute sequentially in declaration order
- Within a
Stop("a", "b")call, components stop in parallel - Each step's
wg.Wait()completes before the next step begins - Components not mentioned in any
Stop()get a catch-all parallel stop after all explicit steps (with a warning log — every component should be in OnShutdown) - A global timeout (default 30s) bounds the entire sequence
Key design principles
- Each
With*option takes aname stringas first argument — used inStop(name)to reference it OnShutdownreads top-to-bottom as a shutdown script- The factory owns
signal.NotifyContext— callers never handle signals defer cleanup()fromNewApplicationnaturally runs afterRun()returns — it is the implicit last phase- Duplicate component names panic at startup — caught immediately
Check procedure:
- If a project uses 2+ transports, verify
server.New()is used (not multipleRun*Servercalls) - Verify
OnShutdownis present and lists all components - Verify shutdown order makes sense: consumers before servers, servers before clients
- No
signal.NotifyContext,net.Listen, orGracefulStopcalls outsidecommon/server/
Correct:
// Trainer: HTTP + gRPC + Watermill consumer
func main() {
logs.Init()
ctx := context.Background()
app, cleanup := service.NewApplication(ctx)
defer cleanup()
server.New(
server.WithWatermillRouter("events", func(r *message.Router, sub message.Subscriber) {
ports.RegisterEventHandlers(r, sub, app)
}),
server.WithHTTPHandler("api", func(router chi.Router) http.Handler {
return ports.HandlerFromMux(ports.NewHttpServer(app), router)
}),
server.WithGRPCServer("grpc", func(s *grpc.Server) {
trainer.RegisterTrainerServiceServer(s, ports.NewGrpcServer(app))
}),
server.OnShutdown(
server.Stop("events"), // 1. stop consuming
server.Stop("api", "grpc"), // 2. drain both servers in parallel
server.StopFunc(cleanup), // 3. close clients & publisher
),
).Run(ctx)
}
// Trainings: HTTP-only, publishes events (publisher in cleanup)
func main() {
logs.Init()
ctx := context.Background()
app, cleanup := service.NewApplication(ctx)
defer cleanup()
server.New(
server.WithHTTPHandler("api", func(router chi.Router) http.Handler {
return ports.HandlerFromMux(ports.NewHttpServer(app), router)
}),
server.OnShutdown(
server.Stop("api"), // 1. drain HTTP (in-flight may publish events)
server.StopFunc(cleanup), // 2. close publisher + gRPC clients
),
).Run(ctx)
}
Wrong:
// VIOLATION: implicit LIFO ordering — fragile
server.New(
server.WithHTTPHandler("api", createHandler),
server.WithWatermillRouter("events", configureRouter),
// no OnShutdown — relies on registration order
).Run(ctx)
// VIOLATION: manual lifecycle per transport
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
grpcServer := grpc.NewServer()
go grpcServer.Serve(lis)
router, _ := message.NewRouter(...)
go router.Run(ctx)
<-ctx.Done()
grpcServer.GracefulStop()
router.Close()
}