Screaming architecture research — 3 approaches for event-sourced Go apps
Find a file
Ash cd198e49f5 refactor: remove CommandName() from all approach_e commands
Commands now route by Go type, not string name. Matches eskit #128.
All tests pass.
2026-02-25 18:29:08 +00:00
approach_a refactor: update all approaches to new EventDispatcher API 2026-02-24 17:06:51 +00:00
approach_b refactor: update all approaches to new EventDispatcher API 2026-02-24 17:06:51 +00:00
approach_c refactor: update all approaches to new EventDispatcher API 2026-02-24 17:06:51 +00:00
approach_d refactor: update all approaches to new EventDispatcher API 2026-02-24 17:06:51 +00:00
approach_e refactor: remove CommandName() from all approach_e commands 2026-02-25 18:29:08 +00:00
README.md refactor: replace integration events with automation pattern (event → command) 2026-02-25 13:32:50 +00:00

Architecture Research: Bounded-Context Package Layout for Go + Event Sourcing

Three approaches to structuring a Go monolith with capability-based bounded contexts. All share the same domain (MarketHub marketplace) and the same design principles:

  • Bounded contexts = business capabilities, not entities
  • One entity (product) split across contexts: catalog (description), pricing (price), inventory (stock)
  • Internal events unexported; significant domain events exported for automations
  • State-view slices: struct + projection + reader + handler
  • Cross-context communication via the automation pattern (event → command), not integration events
  • Decisions stay in ONE context; display is composite

Tree Structure

Approach A — File-per-slice (72 files)

Each concern gets its own file. Commands, events, state, views, automations, routes all separate.

approach_a/
├── go.mod
├── main.go
└── internal/
    ├── eventbus/
    │   └── bus.go
    ├── catalog/
    │   ├── state.go              # CatalogEntry + Evolve
    │   ├── events.go             # unexported internal events
    │   ├── commands.go           # command structs
    │   ├── integration.go        # exported ListingCreated
    │   ├── cmd_create_listing.go # Decide + HTTP handler
    │   ├── cmd_update_description.go
    │   ├── cmd_mark_publishable.go
    │   ├── view_listing.go       # Projection + reader + handler
    │   └── routes.go
    ├── pricing/          (same pattern: ~9 files)
    ├── inventory/        (~10 files)
    ├── sales/            (~8 files)
    ├── finance/          (~9 files)
    ├── fulfillment/      (~8 files)
    ├── identity/         (~10 files)
    └── review/           (~9 files)

Approach B — Phase sub-packages (30 files)

Shared types (state, events, commands, projection) live in the context root. Action-oriented sub-packages (publishing/, ordering/, etc.) contain handlers.

approach_b/
├── go.mod
├── main.go
└── internal/
    ├── eventbus/
    │   └── bus.go
    ├── catalog/
    │   ├── state.go           # CatalogEntry + Evolve
    │   ├── events.go          # EXPORTED events (sub-pkgs need them)
    │   ├── commands.go
    │   ├── integration.go
    │   ├── projection.go      # read model + Projection
    │   └── publishing/
    │       └── create_listing.go  # Decide + handlers + routes
    ├── pricing/
    │   ├── types.go / projection.go
    │   └── setting/handlers.go
    ├── inventory/
    │   ├── types.go / projection.go
    │   └── reserving/handlers.go
    ├── sales/
    │   ├── types.go / projection.go
    │   └── ordering/handlers.go
    ├── finance/
    │   ├── types.go / projection.go
    │   └── paying/handlers.go
    ├── fulfillment/
    │   ├── types.go / projection.go
    │   └── shipping/handlers.go
    ├── identity/
    │   ├── types.go / projection.go
    │   ├── onboarding/handlers.go
    │   └── dashboard/dashboard.go
    └── review/
        ├── types.go / projection.go
        └── approving/handlers.go

Approach C — Single-file contexts (10 files)

Each bounded context is ONE file. State, events, commands, decide functions, projection, automation, HTTP handlers, and routes — all together.

approach_c/
├── go.mod
├── main.go
└── internal/
    ├── eventbus/
    │   └── bus.go
    ├── catalog/
    │   └── catalog.go      # everything for catalog
    ├── pricing/
    │   └── pricing.go
    ├── inventory/
    │   └── inventory.go
    ├── sales/
    │   └── sales.go
    ├── finance/
    │   └── finance.go
    ├── fulfillment/
    │   └── fulfillment.go
    ├── identity/
    │   └── identity.go     # includes both profile + dashboard projections
    └── review/
        └── review.go

Comparison Matrix

Criterion A: File-per-slice B: Phase sub-packages C: Single-file contexts
File count 72 30 10
Conflict resistance ★★★★★ Excellent ★★★★☆ Good ★★☆☆☆ Poor
Navigability ★★★☆☆ Predictable filenames, many files ★★★★☆ Phase dirs group actions ★★★★★ One file = whole context
Circular import safety ★★★★★ No sub-packages ★★★★☆ Child→parent only ★★★★★ No sub-packages
Scalability (large team) ★★★★★ Minimal merge conflicts ★★★★☆ Good ★★☆☆☆ Files get large
Go idiomaticness ★★★★☆ Many small files ★★★☆☆ Deep nesting unusual ★★★★★ Flat, simple
Refactorability ★★★★☆ Move files easily ★★★☆☆ Rename sub-pkgs ★★★★★ Split when needed
Onboarding ease ★★★☆☆ Must learn file conventions ★★★★☆ Structure guides discovery ★★★★★ Read one file

Pros and Cons

Approach A: File-per-slice

Pros:

  • Best conflict resistance — two developers rarely edit the same file
  • Each file has a single, clear responsibility
  • Internal events stay unexported (proper encapsulation)
  • Scales well to large teams and many commands/views

Cons:

  • 72 files is a lot to navigate, even with predictable naming
  • Repetitive boilerplate across contexts (events.go, commands.go, state.go, routes.go)
  • New developers must learn the file naming convention
  • Small changes (e.g., adding a field) may touch 3-4 files

Approach B: Phase sub-packages

Pros:

  • Directory structure communicates business phases (publishing, ordering, shipping)
  • Fewer files than A while maintaining separation
  • Sub-package isolation prevents accidental coupling within a context
  • main.go reads like a wiring diagram of capabilities

Cons:

  • Events must be EXPORTED for sub-packages to use — leaks internal structure
  • Go's import rules mean sub-packages can't import siblings (rigid hierarchy)
  • Deeper directory nesting is unusual in Go ecosystem
  • Renaming a phase means updating import paths everywhere
  • main.go has many imports (both parent and child packages)

Approach C: Single-file contexts

Pros:

  • Minimal file count — entire context visible in one scroll
  • Zero ceremony: no file naming conventions to learn
  • Internal events stay unexported (proper encapsulation)
  • Trivially refactorable — split the file when it gets too big
  • Most Go-idiomatic: flat packages, minimal nesting
  • Fastest to understand for new team members

Cons:

  • Poor conflict resistance — two devs working on same context always collide
  • Files grow large (identity.go is already ~200 lines; in production could be 500+)
  • No structural guidance for "where does this go?" — just append to the file
  • IDE features (outline, go-to) become essential as files grow
  • Doesn't scale well beyond ~5 developers per context

Recommendation

Start with C, graduate to A.

For a new project or small team (≤5 devs), Approach C is the clear winner:

  • Minimal ceremony, maximum readability
  • One file per context means you can understand the whole domain in 8 files
  • Internal events stay properly unexported
  • When a file grows past ~400 lines, split it — you naturally arrive at Approach A

Approach A is the right long-term target for a growing team:

  • Best conflict resistance for parallel development
  • Each file is a self-contained slice that's easy to review, test, and move
  • Maintains proper encapsulation with unexported internal events

Avoid B unless you have a specific reason to enforce phase isolation within a context. The exported-events trade-off and deeper nesting don't pay for themselves in Go, and the rigid sub-package hierarchy creates friction when responsibilities shift.

The key insight: A and C are the same design at different granularities. C is A with all files concatenated. Splitting a C file produces A files. This makes the C→A migration path trivially mechanical.