481 lines
17 KiB
Markdown
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()
|
|
}
|
|
```
|