- Go 100%
|
|
||
|---|---|---|
| approach_a | ||
| approach_b | ||
| approach_c | ||
| approach_d | ||
| approach_e | ||
| README.md | ||
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.goreads 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.gohas 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.