vandor.
Core Concepts

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 table

Problems 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 tracking

Benefits:

  • Rich domain models: Each context has entities with real business behavior
  • Clear boundaries: "Placing an order" lives entirely in the order context
  • 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 wiring

Let'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 context

Real-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.go

Notice 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.go

Anti-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.go

Anti-Pattern 4: God Context

contexts/
└── app/
    # Everything in one context

Fix: 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, schedules

Do not over-engineer early. Start with fewer, larger contexts and split when you see clear seams emerging.

Next Steps