Architecture
One unified structure with clear boundaries between core, transport, and infrastructure
v0.4 Architecture
Vandor v0.4 uses a single unified structure for all projects. There is no multi-template system, no choosing between hexagonal, event-driven, clean, or minimal architectures. Every Vandor project follows the same layout with the same boundaries and the same rules.
One Structure, No Choices
Previous versions of Vandor offered multiple architecture templates. v0.4 removes that choice entirely. Not because choice is bad, but because the right answer turned out to be the same answer every time: a clean separation between domain logic, transport, and infrastructure with strict import rules.
Every Vandor project has this structure:
myapp/
├── cmd/app/main.go
├── internal/
│ ├── core/ # Domain-pure business logic
│ │ ├── contracts/ # Shared domain contracts
│ │ ├── contexts/ # Bounded contexts (business capabilities)
│ │ └── _gen/ # Auto-generated wiring
│ ├── transport/ # Vpkg-managed: HTTP, gRPC, etc.
│ ├── infrastructure/ # Vpkg-managed: databases, caches, queues
│ ├── bootstrap/ # App wiring and runtime
│ │ ├── fx/ # uber-fx dependency injection modules
│ │ └── runtime/ # Runner lifecycle management
│ └── config/ # Typed configuration and validation
├── config/
│ ├── base.yaml # Base configuration
│ └── runner/ # Runner-specific overlays (app.yaml, worker.yaml, etc.)
├── vpkg.yaml # Vpkg desired state
└── vpkg.lock # Vpkg resolved stateThis is the structure. It does not change based on project type, team size, or architectural philosophy.
The Three Zones
The architecture is divided into three zones, each with different ownership rules and import constraints.
Zone 1: Core (You Own This)
internal/core/
├── contracts/
│ ├── command.go # Command pattern contracts
│ ├── tx_contract.go # Transaction abstractions
│ └── event_contract.go # Domain event contracts
├── contexts/
│ └── <context>/
│ ├── domain/
│ │ ├── entity/ # Domain entities with behavior
│ │ ├── valueobject/ # Value objects (immutable, equality by value)
│ │ ├── repository.go # Write repository interface
│ │ ├── read_repository.go # Read repository interface
│ │ └── errors.go # Domain-specific errors
│ ├── application/
│ │ ├── usecase/ # Command-pattern use cases
│ │ └── service/ # Cross-entity business rules
│ ├── module.go # Manual fx module registration
│ └── module_gen.go # Auto-generated fx module wiring
└── _gen/
├── contexts_gen.go # Generated context registration
└── modules_gen.go # Generated module aggregationOwnership: Vandor Core scaffolds this when you run vandor add context. After generation, the code is yours to modify.
Import rules: Core code cannot import anything from transport/, infrastructure/, or bootstrap/fx/. Core is domain-pure. It defines interfaces (repository contracts) that infrastructure implements, but it never references concrete implementations.
This is the most important rule in the entire architecture. If your entity imports a database driver, or your use case imports an HTTP handler, you have broken the boundary and lost the benefits of the design.
Zone 2: Transport and Infrastructure (Vpkg Owns This)
internal/transport/ # HTTP handlers, gRPC services, etc.
internal/infrastructure/ # Database repos, cache clients, queue workers, etc.Ownership: Vpkg packages write code into these directories. When you run vandor vpkg add @official/http-humachi, the package places its files in internal/transport/. When you run vandor vpkg add @official/entgo, it places files in internal/infrastructure/.
Import rules: Transport and infrastructure code can import core interfaces. This is how the layers connect. An HTTP handler imports a use case interface from core. A database repository implements a repository interface defined in core.
// internal/transport/http/handler/order_handler.go
// This is fine -- transport imports core interfaces
import "myapp/internal/core/contexts/order/application/usecase"
// internal/infrastructure/persistence/order_repository.go
// This is fine -- infrastructure implements core interfaces
import "myapp/internal/core/contexts/order/domain"Zone 3: Bootstrap (Wiring Layer)
internal/bootstrap/
├── fx/ # uber-fx module definitions
└── runtime/ # Runner lifecycle (app, worker, scheduler)Ownership: A mix of generated code and project-managed code. The bootstrap layer wires everything together using uber-fx.
Import rules: Bootstrap can import from all zones. It is the only layer that sees the full picture, because its job is to connect core interfaces with transport and infrastructure implementations via dependency injection.
// internal/bootstrap/fx/modules.go
fx.New(
// Core context modules
identity.Module,
order.Module,
// Transport (from Vpkg)
httptransport.Module,
// Infrastructure (from Vpkg)
persistence.Module,
cache.Module,
)Import Rules Summary
These rules are non-negotiable. They are what make the architecture work.
| From | Can Import Core | Can Import Transport | Can Import Infrastructure | Can Import Bootstrap |
|---|---|---|---|---|
| Core | Yes (own context) | No | No | No |
| Transport | Yes (interfaces) | Yes (own layer) | No | No |
| Infrastructure | Yes (interfaces) | No | Yes (own layer) | No |
| Bootstrap | Yes | Yes | Yes | Yes |
The Golden Rule
Core never imports transport, infrastructure, or bootstrap. If you find yourself adding an import from internal/transport/ or internal/infrastructure/ into a file under internal/core/, stop. Define an interface in core and implement it in infrastructure instead.
Folder Contract
Vandor v0.4 has a clear contract about who manages which folders.
Core-Managed (Vandor Core scaffolds, you own)
internal/core/contracts/-- shared domain contractsinternal/core/contexts/<name>/domain/-- entities, value objects, repositoriesinternal/core/contexts/<name>/application/-- use cases and servicesinternal/core/contexts/<name>/module.go-- fx module definitioninternal/core/_gen/-- auto-generated wiring (regenerated onvandor sync)
Vpkg-Managed (Vpkg packages write, you can customize)
internal/transport/-- transport layer code from packages like@official/http-humachiinternal/infrastructure/-- infrastructure code from packages like@official/entgointernal/bootstrap/runtime/-- runtime code from packagesconfig/fragments/-- config fragments from packages
Project-Managed (Fully yours)
cmd/-- entry pointsconfig/base.yaml-- base configurationconfig/runner/-- runner overlay filesinternal/bootstrap/fx/-- custom fx wiringinternal/config/-- typed config structs
Rich Domain Model Convention
Vandor encourages rich domain models -- entities that contain behavior, not just data. In Go, this means using methods on structs to encapsulate business rules and enforce invariants.
Entities Have Behavior
// internal/core/contexts/order/domain/entity/order.go
package entity
type Order struct {
id string
customerID string
items []OrderItem
status OrderStatus
totalAmount Money
createdAt time.Time
}
// Constructor enforces invariants
func NewOrder(customerID string, items []OrderItem) (*Order, error) {
if len(items) == 0 {
return nil, ErrOrderMustHaveItems
}
order := &Order{
id: uuid.New().String(),
customerID: customerID,
items: items,
status: StatusPending,
createdAt: time.Now(),
}
order.recalculateTotal()
return order, nil
}
// Business rule: only pending orders can be confirmed
func (o *Order) Confirm() error {
if o.status != StatusPending {
return ErrCannotConfirmNonPendingOrder
}
o.status = StatusConfirmed
return nil
}
// Business rule: only confirmed orders can be shipped
func (o *Order) Ship(trackingNumber string) error {
if o.status != StatusConfirmed {
return ErrCannotShipUnconfirmedOrder
}
o.status = StatusShipped
return nil
}Value Objects Are Immutable
// internal/core/contexts/order/domain/valueobject/money.go
package valueobject
type Money struct {
amount int64 // Store as cents to avoid floating-point issues
currency string
}
func NewMoney(amount int64, currency string) (Money, error) {
if amount < 0 {
return Money{}, ErrNegativeAmount
}
if currency == "" {
return Money{}, ErrEmptyCurrency
}
return Money{amount: amount, currency: currency}, nil
}
func (m Money) Add(other Money) (Money, error) {
if m.currency != other.currency {
return Money{}, ErrCurrencyMismatch
}
return Money{amount: m.amount + other.amount, currency: m.currency}, nil
}
// Value objects use value equality
func (m Money) Equals(other Money) bool {
return m.amount == other.amount && m.currency == other.currency
}Builders for Complex Construction
When entities have many optional fields, use the builder pattern:
// Builder pattern for complex entity construction
type OrderBuilder struct {
customerID string
items []OrderItem
notes string
priority Priority
}
func NewOrderBuilder(customerID string) *OrderBuilder {
return &OrderBuilder{
customerID: customerID,
priority: PriorityNormal,
}
}
func (b *OrderBuilder) WithItems(items []OrderItem) *OrderBuilder {
b.items = items
return b
}
func (b *OrderBuilder) WithNotes(notes string) *OrderBuilder {
b.notes = notes
return b
}
func (b *OrderBuilder) Build() (*Order, error) {
if len(b.items) == 0 {
return nil, ErrOrderMustHaveItems
}
// construct and validate
return &Order{
id: uuid.New().String(),
customerID: b.customerID,
items: b.items,
notes: b.notes,
priority: b.priority,
status: StatusPending,
}, nil
}Repository Interfaces
Core defines repository interfaces. Infrastructure implements them.
// internal/core/contexts/order/domain/repository.go
package domain
import "context"
// Write repository -- for command operations
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
// Read repository -- for query operations
type OrderReadRepository interface {
FindByCustomerID(ctx context.Context, customerID string) ([]*entity.Order, error)
FindByStatus(ctx context.Context, status OrderStatus) ([]*entity.Order, error)
Count(ctx context.Context) (int64, error)
}Separating write and read repositories gives you the flexibility to optimize reads and writes independently. The write repository uses the domain entity directly. The read repository can return projections or DTOs if needed.
Use Case Pattern
Use cases follow a command pattern with Execute(ctx, input):
// internal/core/contexts/order/application/usecase/place_order.go
package usecase
type PlaceOrderInput struct {
CustomerID string
Items []OrderItemInput
}
type PlaceOrderOutput struct {
OrderID string
}
type PlaceOrder struct {
orderRepo domain.OrderRepository
// Dependencies injected via uber-fx
}
type PlaceOrderDeps struct {
fx.In
OrderRepo domain.OrderRepository
}
func NewPlaceOrder(deps PlaceOrderDeps) *PlaceOrder {
return &PlaceOrder{
orderRepo: deps.OrderRepo,
}
}
func (uc *PlaceOrder) Execute(ctx context.Context, input PlaceOrderInput) (*PlaceOrderOutput, error) {
// Build domain entities
items := mapToOrderItems(input.Items)
// Create order (entity enforces invariants)
order, err := entity.NewOrder(input.CustomerID, items)
if err != nil {
return nil, err
}
// Persist through repository interface
if err := uc.orderRepo.Save(ctx, order); err != nil {
return nil, err
}
return &PlaceOrderOutput{OrderID: order.ID()}, nil
}Why fx.In?
The fx.In embedded struct tells uber-fx how to resolve dependencies. You define what you need as struct fields, and fx wires them automatically at startup. This avoids constructor parameter lists that grow unwieldy as dependencies increase.
Entgo Convention
When using the @official/entgo package, database schemas live outside the core layer:
database/
└── ent/
└── schema/
├── user.go # Entgo schema definition
├── order.go # Entgo schema definition
└── order_item.go # Entgo schema definitionGenerated Entgo client code lives in infrastructure:
internal/infrastructure/persistence/ent/
├── client.go # Generated client
├── user.go # Generated user model
├── order.go # Generated order model
└── ...This keeps the Entgo-generated code (which is infrastructure) separate from your domain entities (which are business logic). Your infrastructure repository implementations translate between Entgo models and domain entities.
Dependency Injection with uber-fx
Vandor uses uber-fx for dependency injection. Each bounded context provides an fx module:
// 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,
service.NewOrderPricingService,
),
)The bootstrap layer aggregates all modules:
// internal/bootstrap/fx/app.go
func NewApp() *fx.App {
return fx.New(
// Core context modules
identity.Module,
order.Module,
catalog.Module,
// Infrastructure modules (from Vpkg)
persistence.Module,
cache.Module,
// Transport modules (from Vpkg)
httptransport.Module,
// Config and runtime
config.Module,
runtime.Module,
)
}Why No Multi-Template System?
Previous versions let you choose between hexagonal, event-driven, clean, and minimal architectures. Here is why v0.4 removed that:
The templates converged. Once you enforce strict import rules (core cannot import transport/infrastructure), separate read and write repositories, and use a command pattern for use cases, the differences between "hexagonal" and "clean architecture" become cosmetic -- folder names and terminology, not actual structural differences.
Template choice was a false decision. Developers spent time choosing between templates when the real work was designing their bounded contexts. The template choice did not meaningfully affect code quality or maintainability.
One structure is easier to learn and teach. Every Vandor project looks the same. Every tutorial, every guide, every Stack Overflow answer applies to every project. There is no "which template are you using?" question.
Mixed architectures within one project are unnecessary. The unified structure already supports simple CRUD contexts (just use a straightforward use case) and complex event-driven contexts (add domain events and sagas within the context). The structure accommodates both without needing separate templates.
Best Practices
1. Respect the Import Rules
This is the most important practice. If you break the import rules, the entire architecture falls apart. Use your IDE or a linter to catch violations.
2. Keep Entities Rich
Entities should contain business logic, not just data. If your entities are just structs with public fields and no methods, you have an anemic domain model. Put behavior where the data is.
3. Use Value Objects
For concepts like money, email addresses, phone numbers, or any value that is defined by its attributes rather than identity, use value objects. They prevent bugs by enforcing invariants at construction time.
4. Design Contexts Around Business Capabilities
Not database tables. Not technical layers. Business capabilities. See the Bounded Contexts page for detailed guidance.
5. Keep Transport Handlers Thin
HTTP handlers should validate the request, call a use case, and format the response. No business logic in handlers. If you are writing an if statement about business rules in a handler, that logic belongs in a use case or entity.
6. Let Infrastructure Be Boring
Repository implementations should be straightforward data access code. Complex query logic can live in read repositories, but business rules belong in the core layer.