threedotslab/references/rules-architecture.md

481 lines
17 KiB
Markdown

# 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:**
1. Glob for these directories relative to the service root
2. Flag any missing standard directories
3. Flag any non-standard directories at the same level (e.g., `controllers/`, `models/`, `handlers/`)
4. 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:**
1. For every `.go` file in `domain/`, scan import statements
2. Flag any import that references `app/`, `ports/`, `adapters/`, or the service's own non-domain packages
3. For every `.go` file in `app/`, scan imports for `ports/` or `adapters/`
4. 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:
1. Initialize cross-cutting concerns (logging)
2. Call `service.NewApplication()`
3. Wire ports (pass `app.Application` to port constructors)
4. Start the server
`main.go` MUST NOT import `adapters/`, create infrastructure clients, or instantiate command/query handlers directly.
**Check procedure:**
1. Scan `main.go` imports — flag any reference to `adapters/`, database drivers, or external service clients
2. Scan all files outside `service/` — flag any call to adapter constructors (e.g., `adapters.New*`)
3. Verify `service/` returns `app.Application`
**Correct:**
```go
// 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:**
```go
// 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:
1. **`NewApplication(ctx) (app.Application, func())`** — production constructor, creates real infrastructure
2. **`NewComponentTestApplication(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:**
1. Look for exported `NewApplication` and `NewComponentTestApplication` in `service/`
2. Verify both call the same unexported function
3. The unexported function MUST accept dependencies as interfaces, not concrete types
**Correct:**
```go
// 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:**
```go
// 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.go` doesn't need to know *what* to clean up — just *that* it must
- Adding new infrastructure only changes `service/`, not `main.go`
**Check procedure:**
1. If `NewApplication` creates closeable resources (clients, connections), it MUST return `func()`
2. `main.go` MUST call `defer cleanup()` immediately after receiving it
3. The cleanup function MUST NOT be ignored (assigned to `_`)
**Correct:**
```go
// 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:**
```go
// 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.go` remains a thin orchestrator: init → wire app → provide handler → run
**Check procedure:**
1. `main.go` MUST call a shared `Run*Server()` function as the final blocking call
2. The callback passed to `Run*Server()` MUST only construct the handler from port constructors — no middleware setup, no router configuration, no listener creation
3. `main.go` MUST NOT import server infrastructure packages (e.g., `net/http.ListenAndServe`, `net.Listen`, middleware libraries)
**Correct:**
```go
// 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:**
```go
// 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:**
1. Scan all files in `service/` for imports of `net`, `os/signal`, `syscall`, transport packages, or `ports/`
2. Flag any function in `service/` that accepts or creates a server, listener, or router
3. A file named `server.go` in `service/` is a strong signal of violation
**Correct:**
```go
// 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:**
```go
// 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
```go
// 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
```go
// 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
1. Steps execute sequentially in declaration order
2. Within a `Stop("a", "b")` call, components stop in parallel
3. Each step's `wg.Wait()` completes before the next step begins
4. 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)
5. A global timeout (default 30s) bounds the entire sequence
### Key design principles
- Each `With*` option takes a `name string` as first argument — used in `Stop(name)` to reference it
- `OnShutdown` reads top-to-bottom as a shutdown script
- The factory owns `signal.NotifyContext` — callers never handle signals
- `defer cleanup()` from `NewApplication` naturally runs after `Run()` returns — it is the implicit last phase
- Duplicate component names panic at startup — caught immediately
**Check procedure:**
1. If a project uses 2+ transports, verify `server.New()` is used (not multiple `Run*Server` calls)
2. Verify `OnShutdown` is present and lists all components
3. Verify shutdown order makes sense: consumers before servers, servers before clients
4. No `signal.NotifyContext`, `net.Listen`, or `GracefulStop` calls outside `common/server/`
**Correct:**
```go
// 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:**
```go
// 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()
}
```