Layered Composition Architecture (LCA)
When This Skill Applies
- •Designing new systems or domains
- •Implementing lifecycle management
- •Creating configuration structures
- •Understanding dependency flow
- •Building runtime/domain separation
- •Creating Infrastructure for shared dependencies
- •Understanding Handler() factory pattern
Principles
1. State Flows Down, Never Up
State flows through method calls (parameters), not through object initialization.
Anti-Pattern (Reaching Up):
type Handler struct {
service *Service // Storing reference to parent
}
func (h *Handler) Process() {
sys := h.service.Providers() // Reaching up to parent state
}
Correct Pattern (State Flows Down):
func HandleCreate(w http.ResponseWriter, r *http.Request, system providers.System, logger *slog.Logger) {
// State injected at call site, flows DOWN
}
2. Systems, Not Services/Models
Terminology:
- •System: A cohesive unit that owns both state and processes
- •State: Structures that define data
- •Process: Methods that operate on state
- •Interface: Contract between systems
Package Organization:
- •cmd/server: The process (composition root, entry point)
- •pkg/: Public API (shared infrastructure, reusable toolkit)
- •internal/: Private API (domain systems, business logic)
3. Cold Start vs Hot Start
Cold Start (State Initialization):
- •
New*()constructor functions - •Builds entire dependency graph
- •All configurations → State objects
- •All systems created but dormant
- •No processes running
- •Returns ready-to-start system
Hot Start (Process Activation):
- •
Start()methods - •State objects → Running processes
- •Cascade start through dependency graph
- •Context boundaries for lifecycle management
- •System becomes interactable
svc, err := NewService(cfg) // Cold Start - Build state graph
if err := svc.Start(); err != nil { // Hot Start - Activate processes
log.Fatal(err)
}
4. System Interface Contract
Every system provides:
- •Internal State (private) - Only accessible within the system
- •Internal Processes (private) - Implementation details
- •Getter Methods (public) - Immutable access to state
- •Commands (public) - Write operations from owner
- •Events (public, optional) - Notifications to owner
5. Infrastructure Pattern
Infrastructure holds core systems required by all modules:
// pkg/runtime/infrastructure.go
type Infrastructure struct {
Lifecycle *lifecycle.Coordinator
Logger *slog.Logger
Database database.System
Storage storage.System
}
func New(cfg *config.Config) (*Infrastructure, error) {
lc := lifecycle.New()
logger := newLogger(&cfg.Logging)
db, err := database.New(&cfg.Database, logger)
if err != nil {
return nil, fmt.Errorf("database init failed: %w", err)
}
store, err := storage.New(&cfg.Storage, logger)
if err != nil {
return nil, fmt.Errorf("storage init failed: %w", err)
}
return &Infrastructure{
Lifecycle: lc,
Logger: logger,
Database: db,
Storage: store,
}, nil
}
func (i *Infrastructure) Start() error {
if err := i.Database.Start(i.Lifecycle); err != nil {
return fmt.Errorf("database start failed: %w", err)
}
if err := i.Storage.Start(i.Lifecycle); err != nil {
return fmt.Errorf("storage start failed: %w", err)
}
return nil
}
6. Runtime/Domain Separation
| Category | Characteristics | Examples |
|---|---|---|
| Runtime Systems | Long-running, lifecycle-managed, application-scoped | Database, Storage |
| Domain Systems | Stateless, request-scoped behavior, no lifecycle | Providers, Agents |
Module Runtime embeds Infrastructure and adds module-specific config:
// internal/api/runtime.go
type Runtime struct {
*runtime.Infrastructure
Pagination pagination.Config
}
func NewRuntime(cfg *config.Config, infra *runtime.Infrastructure) *Runtime {
return &Runtime{
Infrastructure: &runtime.Infrastructure{
Lifecycle: infra.Lifecycle,
Logger: infra.Logger.With("module", "api"),
Database: infra.Database,
Storage: infra.Storage,
},
Pagination: cfg.API.Pagination,
}
}
7. Handler() Factory Pattern
Domain systems expose a Handler() method that creates handlers with the system's dependencies:
// internal/providers/system.go
type System interface {
Handler() *Handler
List(ctx context.Context, page pagination.PageRequest, filters Filters) (*pagination.PageResult[Provider], error)
Find(ctx context.Context, id uuid.UUID) (*Provider, error)
Create(ctx context.Context, cmd CreateCommand) (*Provider, error)
Update(ctx context.Context, id uuid.UUID, cmd UpdateCommand) (*Provider, error)
Delete(ctx context.Context, id uuid.UUID) error
}
// internal/providers/repository.go
func (r *repo) Handler() *Handler {
return NewHandler(r, r.logger, r.pagination)
}
Domain Initialization creates systems with Handler() factory:
// internal/api/domain.go
type Domain struct {
Providers providers.System
Agents agents.System
}
func NewDomain(runtime *Runtime) *Domain {
return &Domain{
Providers: providers.New(runtime.Database.Connection(), runtime.Logger, runtime.Pagination),
Agents: agents.New(runtime.Database.Connection(), runtime.Logger, runtime.Pagination),
}
}
Route Registration uses Handler() to get handlers:
// internal/api/routes.go
func registerRoutes(mux *http.ServeMux, spec *openapi.Spec, domain *Domain, cfg *config.Config) {
routes.Register(mux, cfg.API.BasePath, spec,
domain.Providers.Handler().Routes(),
domain.Agents.Handler().Routes(),
)
}
8. Module Architecture
Modules are self-contained HTTP sub-applications with their own runtime and domain.
Principle: Domains Are Private to Modules
Each module owns its Domain exclusively. Domains never cross module boundaries:
// internal/api/domain.go - PRIVATE to api module
type Domain struct {
Providers providers.System
Agents agents.System
Documents documents.System
}
// The Domain struct is unexported or internal to the module.
// Other modules cannot access or share these systems.
Principle: Config Flows Down to Modules
Root configuration is passed to NewModule(). Modules extract what they need:
// cmd/server/modules.go
apiModule, err := api.NewModule(cfg, &runtime.Infrastructure)
// internal/api/api.go
func NewModule(cfg *config.Config, infra *runtime.Infrastructure) (*module.Module, error) {
// Extract module-specific config
pagination := cfg.API.Pagination
corsConfig := cfg.API.CORS
// Access global config when needed
version := cfg.Version
domain := cfg.Domain
}
Principle: Runtimes Reference Shared Infrastructure
Child runtimes embed or reference parent infrastructure rather than duplicating fields:
// pkg/runtime/infrastructure.go - Shared across modules
type Infrastructure struct {
Logger *slog.Logger
Database database.System
Storage storage.System
Lifecycle *lifecycle.Coordinator
}
// internal/api/runtime.go - Module-specific runtime
type apiRuntime struct {
*runtime.Infrastructure // Embed shared infrastructure
Pagination pagination.Config // Add module-specific fields
}
func newRuntime(cfg *config.Config, infra *runtime.Infrastructure) *apiRuntime {
return &apiRuntime{
Infrastructure: infra,
Pagination: cfg.API.Pagination,
}
}
This pattern:
- •Avoids duplicating fields across runtimes
- •Establishes clear dependency direction (child → parent)
- •Keeps module-specific configuration isolated
9. Lifecycle Coordinator
type Coordinator struct {
ctx context.Context
cancel context.CancelFunc
startupWg sync.WaitGroup
shutdownWg sync.WaitGroup
ready bool
}
func (c *Coordinator) OnStartup(fn func()) // Register startup tasks
func (c *Coordinator) OnShutdown(fn func()) // Register cleanup tasks
func (c *Coordinator) WaitForStartup() // Block until ready
func (c *Coordinator) Ready() bool // Check readiness
Usage Pattern:
- •OnStartup: Tasks that must complete for service readiness (e.g., database ping)
- •OnShutdown: Cleanup tasks triggered on context cancellation (e.g., close connections)
10. Configuration Pattern
Precedence (highest to lowest):
Environment Variables
↓ replaces (not merges)
config.{env}.toml (overlay)
↓ replaces (not merges)
config.toml (base)
Finalize Pattern:
type Config struct {
Server ServerConfig `toml:"server"`
Database DatabaseConfig `toml:"database"`
}
func (c *Config) Finalize() error {
c.loadDefaults() // Apply defaults for zero-value fields
c.loadEnv() // Map SECTION_FIELD environment variables
return c.validate() // Validate field constraints
}
Section Config:
type ServerConfig struct {
Host string `toml:"host"`
Port int `toml:"port"`
}
func (c *ServerConfig) loadDefaults() {
if c.Host == "" { c.Host = "0.0.0.0" }
if c.Port == 0 { c.Port = 8080 }
}
func (c *ServerConfig) loadEnv() {
if v := os.Getenv("SERVER_HOST"); v != "" { c.Host = v }
if v := os.Getenv("SERVER_PORT"); v != "" {
if port, err := strconv.Atoi(v); err == nil { c.Port = port }
}
}
func (c *ServerConfig) validate() error {
if c.Port < 1 || c.Port > 65535 {
return errors.New("port must be between 1 and 65535")
}
return nil
}
Anti-Patterns
Reaching Up for State
// Bad: Handler reaches up to get dependencies
type Handler struct {
app *Application
}
func (h *Handler) Process() {
db := h.app.Database() // Reaching up
}
// Good: Dependencies injected at call site
func HandleProcess(db database.System) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// db available directly
}
}
Merged Configuration
// Bad: Merging arrays from multiple sources
defaults := []string{"a", "b"}
overrides := []string{"c"}
result := append(defaults, overrides...) // ["a", "b", "c"]
// Good: Complete replacement at each level
result := overrides // ["c"] - override replaces entirely