Files

19 KiB

name, model, description, user-invocable
name model description user-invocable
software-architecture haiku Architectural patterns for building systems: DDD, Event Sourcing, event-driven communication. Use when implementing features, reviewing code, planning issues, refining architecture, or making design decisions. Ensures alignment with organizational beliefs about auditability, domain modeling, and independent evolution. false

Software Architecture

Architectural patterns and best practices. This skill is auto-triggered when implementing, reviewing, or planning work that involves architectural decisions.

Architecture Beliefs

These outcome-focused beliefs (from our organization manifesto) guide architectural decisions:

Belief Why It Matters
Auditability by default Systems should remember what happened, not just current state
Business language in code Domain experts' words should appear in the codebase
Independent evolution Parts should change without breaking other parts
Explicit over implicit Intent and side effects should be visible and traceable

Beliefs → Patterns

Belief Primary Pattern Supporting Patterns
Auditability by default Event Sourcing Immutable events, temporal queries
Business language in code Domain-Driven Design Ubiquitous language, aggregates, bounded contexts
Independent evolution Event-driven communication Bounded contexts, published language
Explicit over implicit Commands and Events Domain events, clear intent

Event Sourcing

Achieves: Auditability by default

Instead of storing current state, store the sequence of events that led to it.

Core concepts:

  • Events are immutable facts about what happened, named in past tense: OrderPlaced, PaymentReceived
  • State is derived by replaying events, not stored directly
  • Event store is append-only - history is never modified

Why this matters:

  • Complete audit trail for free
  • Debug by replaying history
  • Answer "what was the state at time X?"
  • Recover from bugs by fixing logic and replaying

Trade-offs:

  • More complex than CRUD for simple cases
  • Requires thinking in events, not state
  • Eventually consistent read models

Domain-Driven Design

Achieves: Business language in code

The domain model reflects how the business thinks and talks.

Core concepts:

  • Ubiquitous language - same terms in code, conversations, and documentation
  • Bounded contexts - explicit boundaries where terms have consistent meaning
  • Aggregates - clusters of objects that change together, with one root entity
  • Domain events - capture what happened in business terms

Why this matters:

  • Domain experts can read and validate the model
  • New team members learn the domain through code
  • Changes in business rules map clearly to code changes

Trade-offs:

  • Upfront investment in understanding the domain
  • Boundaries may need to shift as understanding grows
  • Overkill for pure technical/infrastructure code

Event-Driven Communication

Achieves: Independent evolution

Services communicate by publishing events, not calling each other directly.

Core concepts:

  • Publish events when something important happens
  • Subscribe to events you care about
  • No direct dependencies between publisher and subscriber
  • Eventual consistency - accept that not everything updates instantly

Why this matters:

  • Add new services without changing existing ones
  • Services can be deployed independently
  • Natural resilience - if a subscriber is down, events queue

Trade-offs:

  • Harder to trace request flow
  • Eventual consistency requires different thinking
  • Need infrastructure for reliable event delivery

Commands and Events

Achieves: Explicit over implicit

Distinguish between requests (commands) and facts (events).

Core concepts:

  • Commands express intent: PlaceOrder, CancelSubscription
  • Commands can be rejected (validation, business rules)
  • Events express facts: OrderPlaced, SubscriptionCancelled
  • Events are immutable - what happened, happened

Why this matters:

  • Clear separation of "trying to do X" vs "X happened"
  • Commands validate, events just record
  • Enables replay - reprocess events with new logic

When to Diverge

These patterns are defaults, not mandates. Diverge intentionally when:

  • Simplicity wins - a simple CRUD endpoint doesn't need event sourcing
  • Performance requires it - sometimes synchronous calls are necessary
  • Team context - patterns the team doesn't understand cause more harm than good
  • Prototyping - validate ideas before investing in full architecture

When diverging, document the decision in the project's vision.md Architecture section.

Project-Level Architecture

Each project documents architectural choices in vision.md:

## Architecture

This project follows organization architecture patterns.

### Alignment
- Event sourcing for [which aggregates/domains]
- Bounded contexts: [list contexts and their responsibilities]
- Event-driven communication between [which services]

### Intentional Divergences
| Area | Standard Pattern | What We Do Instead | Why |
|------|------------------|-------------------|-----|

Go-Specific Best Practices

Package Organization

Good package structure:

project/
├── cmd/                    # Application entry points
│   └── server/
│       └── main.go
├── internal/               # Private packages
│   ├── domain/             # Core business logic
│   │   ├── user/
│   │   └── order/
│   ├── service/            # Application services
│   ├── repository/         # Data access
│   └── handler/            # HTTP/gRPC handlers
├── pkg/                    # Public, reusable packages
└── go.mod

Package naming:

  • Short, concise, lowercase: user, order, auth
  • Avoid generic names: util, common, helpers, misc
  • Name after what it provides, not what it contains
  • One package per concept, not per file

Package cohesion:

  • A package should have a single, focused responsibility
  • Package internal files can use internal types freely
  • Minimize exported types - export interfaces, hide implementations

Interfaces

Accept interfaces, return structs:

// Good: Accept interface, return concrete type
func NewUserService(repo UserRepository) *UserService {
    return &UserService{repo: repo}
}

// Bad: Accept and return interface
func NewUserService(repo UserRepository) UserService {
    return &userService{repo: repo}
}

Define interfaces at point of use:

// Good: Interface defined where it's used (consumer owns the interface)
package service

type UserRepository interface {
    FindByID(ctx context.Context, id string) (*User, error)
}

// Bad: Interface defined with implementation (producer owns the interface)
package repository

type UserRepository interface {
    FindByID(ctx context.Context, id string) (*User, error)
}

Keep interfaces small:

  • Prefer single-method interfaces
  • Large interfaces indicate missing abstraction
  • Compose small interfaces when needed

Error Handling

Wrap errors with context:

// Good: Wrap with context
if err != nil {
    return fmt.Errorf("fetching user %s: %w", id, err)
}

// Bad: Return bare error
if err != nil {
    return err
}

Use sentinel errors for expected conditions:

var ErrNotFound = errors.New("not found")
var ErrConflict = errors.New("conflict")

// Check with errors.Is
if errors.Is(err, ErrNotFound) {
    // handle not found
}

Error types for rich errors:

type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("%s: %s", e.Field, e.Message)
}

// Check with errors.As
var valErr *ValidationError
if errors.As(err, &valErr) {
    // handle validation error
}

Dependency Injection

Constructor injection:

type UserService struct {
    repo   UserRepository
    logger Logger
}

func NewUserService(repo UserRepository, logger Logger) *UserService {
    return &UserService{
        repo:   repo,
        logger: logger,
    }
}

Wire dependencies in main:

func main() {
    // Create dependencies
    db := database.Connect()
    logger := slog.Default()

    // Wire up services
    userRepo := repository.NewUserRepository(db)
    userService := service.NewUserService(userRepo, logger)
    userHandler := handler.NewUserHandler(userService)

    // Start server
    http.Handle("/users", userHandler)
    http.ListenAndServe(":8080", nil)
}

Avoid global state:

  • No init() for service initialization
  • No package-level variables for dependencies
  • Pass context explicitly, don't store in structs

Testing

Table-driven tests:

func TestUserService_Create(t *testing.T) {
    tests := []struct {
        name    string
        input   CreateUserInput
        want    *User
        wantErr error
    }{
        {
            name:  "valid user",
            input: CreateUserInput{Email: "test@example.com"},
            want:  &User{Email: "test@example.com"},
        },
        {
            name:    "invalid email",
            input:   CreateUserInput{Email: "invalid"},
            wantErr: ErrInvalidEmail,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            // arrange, act, assert
        })
    }
}

Test doubles:

  • Use interfaces for test doubles
  • Prefer hand-written mocks over generated ones for simple cases
  • Use testify/mock or gomock for complex mocking needs

Test package naming:

  • package user_test for black-box testing (preferred)
  • package user for white-box testing when needed

Generic Architecture Patterns

Layered Architecture

┌─────────────────────────────────┐
│         Presentation            │  HTTP handlers, CLI, gRPC
├─────────────────────────────────┤
│         Application             │  Use cases, orchestration
├─────────────────────────────────┤
│           Domain                │  Business logic, entities
├─────────────────────────────────┤
│        Infrastructure           │  Database, external services
└─────────────────────────────────┘

Rules:

  • Dependencies point downward only
  • Upper layers depend on interfaces, not implementations
  • Domain layer has no external dependencies

SOLID Principles

Single Responsibility (S):

  • Each module has one reason to change
  • Split code that changes for different reasons

Open/Closed (O):

  • Open for extension, closed for modification
  • Add new behavior through new types, not changing existing ones

Liskov Substitution (L):

  • Subtypes must be substitutable for their base types
  • Interfaces should be implementable without surprises

Interface Segregation (I):

  • Clients shouldn't depend on interfaces they don't use
  • Prefer many small interfaces over few large ones

Dependency Inversion (D):

  • High-level modules shouldn't depend on low-level modules
  • Both should depend on abstractions

Dependency Direction

                    ┌──────────────┐
                    │    Domain    │
                    │  (no deps)   │
                    └──────────────┘
                           ▲
              ┌────────────┴────────────┐
              │                         │
      ┌───────┴───────┐         ┌───────┴───────┐
      │  Application  │         │Infrastructure │
      │ (uses domain) │         │(implements    │
      └───────────────┘         │ domain intf)  │
              ▲                 └───────────────┘
              │
      ┌───────┴───────┐
      │ Presentation  │
      │(calls app)    │
      └───────────────┘

Key insight: Infrastructure implements domain interfaces, doesn't define them. This inverts the "natural" dependency direction.

Module Boundaries

Signs of good boundaries:

  • Modules can be understood in isolation
  • Changes are localized within modules
  • Clear, minimal public API
  • Dependencies flow in one direction

Signs of bad boundaries:

  • Circular dependencies between modules
  • "Shotgun surgery" - small changes require many file edits
  • Modules reach into each other's internals
  • Unclear ownership of concepts

Repository Health Indicators

Positive Indicators

Indicator What to Look For
Clear structure Obvious package organization, consistent naming
Small interfaces Most interfaces have 1-3 methods
Explicit dependencies Constructor injection, no globals
Test coverage Unit tests for business logic, integration tests for boundaries
Error handling Wrapped errors, typed errors for expected cases
Documentation CLAUDE.md accurate, code comments explain "why"

Warning Signs

Indicator What to Look For
God packages utils/, common/, helpers/ with 20+ files
Circular deps Package A imports B, B imports A
Deep nesting 4+ levels of directory nesting
Huge files Files with 500+ lines
Interface pollution Interfaces for everything, even single implementations
Global state Package-level variables, init() for setup

Metrics to Track

  • Package fan-out: How many packages does each package import?
  • Cyclomatic complexity: How complex are the functions?
  • Test coverage: What percentage of code is tested?
  • Import depth: How deep is the import tree?

Review Checklists

Repository Audit Checklist

Use this when evaluating overall repository health.

Structure:

  • Clear package organization following Go conventions
  • No circular dependencies between packages
  • Appropriate use of internal/ for private packages
  • cmd/ for application entry points

Dependencies:

  • Dependencies flow inward (toward domain)
  • Interfaces defined at point of use (not with implementation)
  • No global state or package-level dependencies
  • Constructor injection throughout

Code Quality:

  • Consistent naming conventions
  • No "god" packages (utils, common, helpers)
  • Errors wrapped with context
  • Small, focused interfaces

Testing:

  • Unit tests for domain logic
  • Integration tests for boundaries (DB, HTTP)
  • Tests are readable and maintainable
  • Test coverage for critical paths

Documentation:

  • CLAUDE.md is accurate and helpful
  • vision.md explains the product purpose
  • Code comments explain "why", not "what"

Issue Refinement Checklist

Use this when reviewing issues for architecture impact.

Scope:

  • Issue is a vertical slice (user-visible value)
  • Changes are localized to specific packages
  • No cross-cutting concerns hidden in implementation

Design:

  • Follows existing patterns in the codebase
  • New abstractions are justified
  • Interface changes are backward compatible (or breaking change is documented)

Dependencies:

  • New dependencies are minimal and justified
  • No new circular dependencies introduced
  • Integration points are clearly defined

Testability:

  • Acceptance criteria are testable
  • New code can be unit tested in isolation
  • Integration test requirements are clear

PR Review Checklist

Use this when reviewing pull requests for architecture concerns.

Structure:

  • Changes respect existing package boundaries
  • New packages follow naming conventions
  • No new circular dependencies

Interfaces:

  • Interfaces are defined where used
  • Interfaces are minimal and focused
  • Breaking interface changes are justified

Dependencies:

  • Dependencies injected via constructors
  • No new global state
  • External dependencies properly abstracted

Error Handling:

  • Errors wrapped with context
  • Sentinel errors for expected conditions
  • Error types for rich error information

Testing:

  • New code has appropriate test coverage
  • Tests are clear and maintainable
  • Edge cases covered

Anti-Patterns to Flag

God Packages

Problem: Packages like utils/, common/, helpers/ become dumping grounds.

Symptoms:

  • 20+ files in one package
  • Unrelated functions grouped together
  • Package imported by everything

Fix: Extract cohesive packages based on what they provide: validation, httputil, timeutil.

Circular Dependencies

Problem: Package A imports B, and B imports A (directly or transitively).

Symptoms:

  • Import cycle compile errors
  • Difficulty understanding code flow
  • Changes cascade unexpectedly

Fix:

  • Extract shared types to a third package
  • Use interfaces to invert dependency
  • Merge packages if truly coupled

Leaky Abstractions

Problem: Implementation details leak through abstraction boundaries.

Symptoms:

  • Database types in domain layer
  • HTTP types in service layer
  • Framework types in business logic

Fix: Define types at each layer, map between them explicitly.

Anemic Domain Model

Problem: Domain objects are just data containers, logic is elsewhere.

Symptoms:

  • Domain types have only getters/setters
  • All logic in "service" classes
  • Domain types can be in invalid states

Fix: Put behavior with data. Domain types should enforce their own invariants.

Shotgun Surgery

Problem: Small changes require editing many files across packages.

Symptoms:

  • Feature adds touch 10+ files
  • Similar changes in multiple places
  • Copy-paste between packages

Fix: Consolidate related code. If things change together, they belong together.

Feature Envy

Problem: Code in one package is more interested in another package's data.

Symptoms:

  • Many calls to another package's methods
  • Pulling data just to compute something
  • Logic that belongs elsewhere

Fix: Move the code to where the data lives, or extract the behavior to a shared place.

Premature Abstraction

Problem: Creating interfaces and abstractions before they're needed.

Symptoms:

  • Interfaces with single implementations
  • "Factory" and "Manager" classes everywhere
  • Configuration for things that never change

Fix: Write concrete code first. Extract abstractions when you have multiple implementations or need to break dependencies.

Deep Hierarchy

Problem: Excessive layers of abstraction or inheritance.

Symptoms:

  • 5+ levels of embedding/composition
  • Hard to trace code flow
  • Changes require understanding many layers

Fix: Prefer composition over inheritance. Flatten hierarchies where possible.