Bounded Contexts
Organizing code around business capabilities, not database tables
The Most Important Concept
If you take one thing away from all of the Vandor documentation, make it this: a bounded context represents a business capability, not a database table.
Getting this right makes your code easier to understand, easier to change, easier to test, and easier to scale. Getting it wrong leads to anemic models, tight coupling, and maintenance nightmares that compound over time.
In Vandor, we use the term "context" throughout the CLI and the project structure. When you run vandor add context, you are adding a bounded context -- a distinct area of business capability with its own entities, value objects, use cases, and services.
What is a Bounded Context?
A bounded context is a distinct area of business capability. It represents what your system does, not what it stores.
Think about it from the perspective of someone describing your business to a new employee. They would not say "we have a users table, a products table, and an order_items table." They would say "we handle customer identity, we manage a product catalog, and we process orders." Those are your bounded contexts.
Wrong: Table-Based Contexts
internal/core/contexts/
├── user/ # This is a database table
├── product/ # This is a database table
├── order_item/ # This is a database table
├── payment/ # This is a database table
└── shipping_address/ # This is a database tableProblems with this approach:
- Anemic models: Each "context" is just a data container with getters and setters
- No business boundaries: Where does "placing an order" live? It touches user, product, order_item, payment, and shipping_address
- Tight coupling: Everything references everything else through database foreign keys
- Team confusion: Who owns "payment"? The order team? The billing team? The user team?
- Hard to extract: Try pulling "order processing" into a microservice when it is scattered across five table-based packages
Right: Capability-Based Contexts
internal/core/contexts/
├── identity/ # Business capability: user management, authentication, profiles
├── catalog/ # Business capability: products, categories, inventory, pricing
├── order/ # Business capability: order processing, payments, shipments
└── shipping/ # Business capability: carrier management, delivery trackingBenefits:
- Rich domain models: Each context has entities with real business behavior
- Clear boundaries: "Placing an order" lives entirely in the
ordercontext - Loose coupling: Contexts communicate through interfaces, not database joins
- Team ownership: The order team owns everything about order processing
- Microservice-ready: Each context can become a service with minimal effort
Context Structure
Every bounded context in Vandor follows the same internal structure:
internal/core/contexts/<context>/
├── domain/
│ ├── entity/ # Domain entities with behavior
│ │ ├── order.go
│ │ └── order_item.go
│ ├── valueobject/ # Immutable value objects
│ │ ├── money.go
│ │ └── order_status.go
│ ├── repository.go # Write repository interface
│ ├── read_repository.go # Read repository interface
│ └── errors.go # Domain-specific errors
├── application/
│ ├── usecase/ # Command-pattern use cases
│ │ ├── place_order.go
│ │ ├── cancel_order.go
│ │ └── get_order.go
│ └── service/ # Cross-entity business rules
│ └── pricing_service.go
├── module.go # fx module definition
└── module_gen.go # Auto-generated fx wiringLet's walk through each part.
Domain Layer
The domain layer is the heart of the context. It contains the business rules, expressed through entities and value objects.
Entities have identity (an ID) and mutable state. They contain behavior -- methods that enforce business rules:
// internal/core/contexts/order/domain/entity/order.go
package entity
type Order struct {
id string
customerID string
items []OrderItem
status valueobject.OrderStatus
totalAmount valueobject.Money
createdAt time.Time
updatedAt time.Time
}
// Constructor enforces required invariants
func NewOrder(customerID string, items []OrderItem) (*Order, error) {
if customerID == "" {
return nil, errors.ErrCustomerIDRequired
}
if len(items) == 0 {
return nil, errors.ErrOrderMustHaveItems
}
order := &Order{
id: uuid.New().String(),
customerID: customerID,
items: items,
status: valueobject.StatusPending,
createdAt: time.Now(),
updatedAt: time.Now(),
}
order.recalculateTotal()
return order, nil
}
// Business rule: only pending orders can be cancelled
func (o *Order) Cancel(reason string) error {
if o.status != valueobject.StatusPending {
return errors.ErrCanOnlyCancelPendingOrders
}
o.status = valueobject.StatusCancelled
o.updatedAt = time.Now()
return nil
}
func (o *Order) recalculateTotal() {
var total int64
for _, item := range o.items {
total += item.Subtotal().Amount()
}
o.totalAmount, _ = valueobject.NewMoney(total, "USD")
}Value objects are immutable and compared by value, not identity:
// internal/core/contexts/order/domain/valueobject/order_status.go
package valueobject
type OrderStatus string
const (
StatusPending OrderStatus = "pending"
StatusConfirmed OrderStatus = "confirmed"
StatusShipped OrderStatus = "shipped"
StatusDelivered OrderStatus = "delivered"
StatusCancelled OrderStatus = "cancelled"
)
func (s OrderStatus) IsTerminal() bool {
return s == StatusDelivered || s == StatusCancelled
}Repository interfaces define the contract for data access. The core layer defines what it needs; infrastructure provides the implementation:
// internal/core/contexts/order/domain/repository.go
package domain
import "context"
type OrderRepository interface {
Save(ctx context.Context, order *entity.Order) error
Update(ctx context.Context, order *entity.Order) error
Delete(ctx context.Context, id string) error
FindByID(ctx context.Context, id string) (*entity.Order, error)
}// internal/core/contexts/order/domain/read_repository.go
package domain
type OrderReadRepository interface {
FindByCustomerID(ctx context.Context, customerID string) ([]*entity.Order, error)
FindByStatus(ctx context.Context, status valueobject.OrderStatus) ([]*entity.Order, error)
ListPaginated(ctx context.Context, offset, limit int) ([]*entity.Order, int64, error)
}Domain errors are specific and meaningful:
// internal/core/contexts/order/domain/errors.go
package domain
import "errors"
var (
ErrOrderNotFound = errors.New("order not found")
ErrOrderMustHaveItems = errors.New("order must have at least one item")
ErrCustomerIDRequired = errors.New("customer ID is required")
ErrCanOnlyCancelPendingOrders = errors.New("only pending orders can be cancelled")
ErrCannotShipUnconfirmedOrder = errors.New("cannot ship an unconfirmed order")
)Application Layer
The application layer contains use cases and services. Use cases follow a command pattern.
Use cases represent a single operation that the application can perform:
// internal/core/contexts/order/application/usecase/place_order.go
package usecase
import "go.uber.org/fx"
type PlaceOrderInput struct {
CustomerID string
Items []PlaceOrderItemInput
}
type PlaceOrderItemInput struct {
ProductID string
Quantity int
Price int64
}
type PlaceOrderOutput struct {
OrderID string
TotalAmount int64
}
type PlaceOrderDeps struct {
fx.In
OrderRepo domain.OrderRepository
}
type PlaceOrder struct {
orderRepo domain.OrderRepository
}
func NewPlaceOrder(deps PlaceOrderDeps) *PlaceOrder {
return &PlaceOrder{
orderRepo: deps.OrderRepo,
}
}
func (uc *PlaceOrder) Execute(ctx context.Context, input PlaceOrderInput) (*PlaceOrderOutput, error) {
// Map input to domain entities
items := make([]entity.OrderItem, 0, len(input.Items))
for _, item := range input.Items {
orderItem, err := entity.NewOrderItem(item.ProductID, item.Quantity, item.Price)
if err != nil {
return nil, fmt.Errorf("invalid order item: %w", err)
}
items = append(items, *orderItem)
}
// Create order (entity enforces business rules)
order, err := entity.NewOrder(input.CustomerID, items)
if err != nil {
return nil, err
}
// Persist
if err := uc.orderRepo.Save(ctx, order); err != nil {
return nil, fmt.Errorf("failed to save order: %w", err)
}
return &PlaceOrderOutput{
OrderID: order.ID(),
TotalAmount: order.TotalAmount().Amount(),
}, nil
}Notice the pattern: every use case has an Execute(ctx, input) method. This consistency makes use cases predictable and testable.
Services handle business rules that span multiple entities within the same context:
// internal/core/contexts/order/application/service/pricing_service.go
package service
type PricingService struct {
// Dependencies for pricing calculations
}
func NewPricingService() *PricingService {
return &PricingService{}
}
// ApplyDiscount calculates discounts based on business rules
// that involve multiple entities (order + customer tier + promotions)
func (s *PricingService) ApplyDiscount(order *entity.Order, customerTier string) (*entity.Order, error) {
discount := s.calculateDiscount(order.TotalAmount(), customerTier)
return order.ApplyDiscount(discount)
}Use Case vs Service
A use case represents a complete user-facing operation (place order, cancel order). A service provides a shared business capability that multiple use cases might need (pricing calculations, inventory validation). If a piece of logic is only used by one use case, put it in that use case. If multiple use cases share it, extract it into a service.
Module Wiring
Each context provides an fx module that registers its use cases and services:
// internal/core/contexts/order/module.go
package order
import "go.uber.org/fx"
var Module = fx.Module("order",
fx.Provide(
usecase.NewPlaceOrder,
usecase.NewCancelOrder,
usecase.NewGetOrder,
usecase.NewListOrders,
service.NewPricingService,
),
)The module_gen.go file is auto-generated by Vandor and typically handles additional wiring. You should not edit it manually -- it gets regenerated when you run vandor sync.
How to Identify Bounded Contexts
Strategy 1: Business Capabilities
Ask: "What does this business do?" Each major capability is likely a bounded context.
HRIS System:
- Manage employee identities and authentication -- identity
- Process payroll and compensation -- payroll
- Track attendance and leave -- attendance
- Handle recruitment and hiring -- recruitment
- Manage employee learning and development -- learning
E-Commerce Platform:
- Manage customer accounts -- identity
- Curate product catalog -- catalog
- Process customer orders -- order
- Handle shipping and delivery -- shipping
- Collect and display reviews -- review
SaaS Project Management Tool:
- Manage users, orgs, and roles -- identity
- Manage projects and tasks -- workspace
- Handle comments and collaboration -- collaboration
- Process billing and subscriptions -- billing
Strategy 2: Event Storming
List all the things that happen in your system, then group the related ones:
- EmployeeRegistered, EmployeeLoggedIn, RoleAssigned, PasswordChanged -- identity
- PayRunInitiated, SalaryCalculated, DeductionApplied, PayslipGenerated -- payroll
- ClockIn, ClockOut, LeaveRequested, LeaveApproved, SchedulePublished -- attendance
- JobPosted, ApplicationReceived, InterviewScheduled, OfferExtended -- recruitment
Strategy 3: Bounded Contexts (DDD)
Look for concepts that have different meanings in different parts of the system:
The concept of "Employee" means different things in different contexts:
- In identity: login credentials, roles, profile photo
- In payroll: salary grade, bank account, tax information
- In attendance: shift schedule, leave balance, overtime hours
- In recruitment: hiring date, probation status, onboarding checklist
These different perspectives suggest separate bounded contexts.
Strategy 4: Transaction Boundaries
What data must be consistent together? That data likely belongs in one context.
An order, its line items, the payment record, and the shipping address must all be consistent when an order is placed. They belong together in the order context.
An employee's profile photo and their salary grade do not need to be consistent together. They can live in different contexts (identity and payroll).
Inter-Context Communication
Contexts should have minimal coupling. When they need to communicate, they do so through interfaces -- never through direct imports of another context's entities.
Through Interfaces (Correct)
// The order context needs to validate that a customer exists.
// It defines an interface for what it needs:
// internal/core/contexts/order/application/usecase/place_order.go
type CustomerValidator interface {
Exists(ctx context.Context, customerID string) (bool, error)
}
type PlaceOrderDeps struct {
fx.In
OrderRepo domain.OrderRepository
CustomerValidator CustomerValidator
}The identity context provides an implementation, and uber-fx wires them together. The order context never imports identity code directly.
Direct Entity Import (Wrong)
// DO NOT do this -- direct coupling between contexts
import identityEntity "myapp/internal/core/contexts/identity/domain/entity"
type Order struct {
Customer *identityEntity.User // Direct coupling!
}This creates tight coupling. If the identity context changes its User entity, the order context breaks.
Through Domain Events (Advanced)
For async communication between contexts, use domain events:
// internal/core/contracts/event_contract.go
type DomainEvent interface {
EventName() string
OccurredAt() time.Time
}
// internal/core/contexts/order/domain/entity/order.go
// When an order is placed, emit an event
type OrderPlacedEvent struct {
OrderID string
CustomerID string
Amount int64
OccurredAt time.Time
}
// The notification context listens for this event
// and sends a confirmation email -- without importing the order contextReal-World Example: HRIS System
Here is how a real HRIS application maps to bounded contexts:
internal/core/contexts/
├── identity/
│ ├── domain/
│ │ ├── entity/
│ │ │ ├── employee.go # Employee account, credentials
│ │ │ └── role.go # RBAC roles
│ │ ├── valueobject/
│ │ │ └── email.go # Validated email value object
│ │ ├── repository.go
│ │ └── errors.go
│ └── application/
│ └── usecase/
│ ├── register_employee.go
│ ├── authenticate.go
│ └── assign_role.go
│
├── payroll/
│ ├── domain/
│ │ ├── entity/
│ │ │ ├── pay_run.go # Payroll processing run
│ │ │ ├── payslip.go # Individual payslip
│ │ │ └── deduction.go # Tax, insurance deductions
│ │ ├── valueobject/
│ │ │ ├── salary_grade.go
│ │ │ └── money.go
│ │ ├── repository.go
│ │ └── errors.go
│ └── application/
│ ├── usecase/
│ │ ├── initiate_pay_run.go
│ │ ├── calculate_salary.go
│ │ └── generate_payslip.go
│ └── service/
│ └── tax_calculation_service.go
│
├── attendance/
│ ├── domain/
│ │ ├── entity/
│ │ │ ├── attendance_record.go
│ │ │ ├── leave_request.go
│ │ │ └── shift_schedule.go
│ │ ├── valueobject/
│ │ │ ├── time_range.go
│ │ │ └── leave_type.go
│ │ ├── repository.go
│ │ └── errors.go
│ └── application/
│ └── usecase/
│ ├── clock_in.go
│ ├── clock_out.go
│ ├── request_leave.go
│ └── approve_leave.go
│
└── recruitment/
├── domain/
│ ├── entity/
│ │ ├── job_posting.go
│ │ ├── application.go
│ │ └── interview.go
│ ├── valueobject/
│ │ ├── job_status.go
│ │ └── application_stage.go
│ ├── repository.go
│ └── errors.go
└── application/
└── usecase/
├── post_job.go
├── submit_application.go
└── schedule_interview.goNotice how each context is self-contained. The payroll context does not import from identity. If payroll needs to know an employee's name for a payslip, it defines its own interface and gets that data through dependency injection.
Common Anti-Patterns
Anti-Pattern 1: One Table = One Context
contexts/
├── employee/
├── employee_address/
├── employee_preference/
└── employee_session/Fix: These are all part of the identity context:
contexts/identity/
└── domain/entity/
├── employee.go
├── address.go
├── preference.go
└── session.goAnti-Pattern 2: Technical Grouping
contexts/
├── api/
├── database/
├── cache/
└── queue/Fix: These are infrastructure concerns, not business capabilities. They belong in internal/transport/ and internal/infrastructure/, managed by Vpkg packages.
Anti-Pattern 3: Too Many Tiny Contexts
contexts/
├── employee_registration/
├── employee_login/
├── employee_password_reset/
├── employee_profile_update/
└── employee_email_verification/Fix: These are all use cases within one context:
contexts/identity/
└── application/usecase/
├── register_employee.go
├── authenticate.go
├── reset_password.go
├── update_profile.go
└── verify_email.goAnti-Pattern 4: God Context
contexts/
└── app/
# Everything in one contextFix: Split into meaningful capabilities based on your business. If you are unsure where to draw the lines, use event storming.
Context Size Guidelines
Too Small: A context with only one use case and no real business rules. This is probably a use case inside a larger context.
Too Large: A context where unrelated business rules are mixed together and different teams need to own different parts. This should be split.
Just Right: A context with 3-10 use cases, cohesive business rules, clear ownership, and a natural boundary that would make sense as a microservice if you ever needed to extract it.
Testing Bounded Contexts
Well-designed contexts are easy to test because they have no infrastructure dependencies:
func TestPlaceOrder(t *testing.T) {
// No database, no HTTP, no cache -- just a mock repository
mockRepo := &MockOrderRepository{}
deps := usecase.PlaceOrderDeps{
OrderRepo: mockRepo,
}
uc := usecase.NewPlaceOrder(deps)
input := usecase.PlaceOrderInput{
CustomerID: "cust-123",
Items: []usecase.PlaceOrderItemInput{
{ProductID: "prod-1", Quantity: 2, Price: 1500},
},
}
result, err := uc.Execute(context.Background(), input)
assert.NoError(t, err)
assert.NotEmpty(t, result.OrderID)
assert.Equal(t, int64(3000), result.TotalAmount)
assert.True(t, mockRepo.SaveCalled)
}If you find yourself needing a database connection, an HTTP server, or any other infrastructure to test domain logic, your context boundaries are wrong. The core layer should be testable with nothing but mocks.
Evolving Contexts Over Time
Your understanding of domain boundaries will improve over time. That is normal and expected.
Start Combined, Split When Needed
Early in a project, you might have a single employee context. As the system grows, you realize that "employee management" is actually three distinct capabilities:
# Phase 1: Start simple
contexts/
└── employee/
# Phase 2: Split when complexity justifies it
contexts/
├── identity/ # Authentication, roles, profiles
├── payroll/ # Compensation, deductions, payslips
└── attendance/ # Time tracking, leave, schedulesDo not over-engineer early. Start with fewer, larger contexts and split when you see clear seams emerging.