Architectural guidelines for Go projects following Domain-Driven Design (DDD) and Clean Architecture principles. Focuses on layer boundaries, dependency rules, and rich domain models.
This guide explains the DDD and CQRS principles demonstrated in this codebase, presented in a way that allows engineers to apply these patterns to any industry or business domain.
Domain-Driven Design (DDD) is a software development approach that:
This implementation follows the Onion Architecture pattern with clear separation of concerns:
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā Interface Layer ā ā External APIs, Controllers
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā¤
ā Application Layer ā ā Use Cases, Commands, Queries
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā¤
ā Domain Layer ā ā Business Logic, Entities
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā¤
ā Infrastructure Layer ā ā Database, External Services
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
type Entity struct {
ID uuid.UUID // Always use unique identifiers
CreatedAt time.Time // Set by domain, not database
UpdatedAt time.Time // Updated on modifications
// Business attributes
}
Key Principles:
// Private validation method
func (e *Entity) validate() error {
// Business rule validations
if e.BusinessAttribute == "" {
return errors.New("business rule violation")
}
return nil
}
// Public modification method with validation
func (e *Entity) UpdateAttribute(value string) error {
// Validate BEFORE modifying state
if value == "" {
return errors.New("business rule violation")
}
// Only modify if validation passes
e.BusinessAttribute = value
e.UpdatedAt = time.Now()
return nil
}
type ValidatedEntity struct {
Entity
isValidated bool
}
func NewValidatedEntity(entity *Entity) (*ValidatedEntity, error) {
if err := entity.validate(); err != nil {
return nil, err
}
return &ValidatedEntity{
Entity: *entity,
isValidated: true,
}, nil
}
Purpose: Ensures only valid entities can be persisted
type EntityRepository interface {
Create(entity *ValidatedEntity) (*Entity, error)
FindByID(id uuid.UUID) (*Entity, error)
FindAll() ([]*Entity, error)
Update(entity *ValidatedEntity) (*Entity, error)
Delete(id uuid.UUID) error
}
Key Points:
type EntityService struct {
repo repositories.EntityRepository
idempotencyRepo repositories.IdempotencyRepository
}
Services orchestrate:
type SqlcEntityRepository struct {
queries *db.Queries
}
func (repo *SqlcEntityRepository) Create(entity *ValidatedEntity) (*Entity, error) {
ctx := context.Background()
dbEntity, err := repo.queries.CreateEntity(ctx, db.CreateEntityParams{
ID: entity.ID,
Name: entity.Name,
CreatedAt: timestamptzFromTime(entity.CreatedAt),
UpdatedAt: timestamptzFromTime(entity.UpdatedAt),
})
if err != nil {
return nil, err
}
// Always read after write
return repo.FindByID(dbEntity.ID)
}
// Domain to Database
func toDBModel(entity *ValidatedEntity) *DBModel {
// Map domain entity to database model
}
// Database to Domain
func fromDBModel(dbModel *DBModel) *Entity {
// Map database model to domain entity
}
Purpose: Keep domain models pure and database concerns isolated
Commands modify state and are task-oriented:
type CreateEntityCommand struct {
IdempotencyKey string
// Business attributes
}
type CreateEntityCommandResult struct {
Result *EntityResult
}
Queries retrieve data without side effects:
// For queries with parameters
type GetEntityByIDQuery struct {
ID uuid.UUID
}
type GetEntityByIDQueryResult struct {
Result *EntityResult
}
// For simple parameterless queries, use direct method calls
func (s *EntityService) FindAllEntities() (*EntityQueryListResult, error) {
// Simple queries don't need query objects
}
// For complex queries with filters/parameters, use query objects
func (s *EntityService) FindEntitiesByCategory(query *GetEntitiesByCategoryQuery) (*EntityQueryListResult, error) {
// Complex queries benefit from query objects
}
Query Object Guidelines:
// Check for existing execution
if command.IdempotencyKey != "" {
existing, err := idempotencyRepo.FindByKey(ctx, command.IdempotencyKey)
if existing != nil {
return cachedResponse, nil
}
}
// Execute business logic
result := executeBusinessLogic()
// Store result for future requests
if command.IdempotencyKey != "" {
record := NewIdempotencyRecord(command.IdempotencyKey, request)
record.SetResponse(response, statusCode)
idempotencyRepo.Create(ctx, record)
}
func NewEntity(businessAttribute string) *Entity {
return &Entity{
ID: uuid.New(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
BusinessAttribute: businessAttribute,
}
}
Always return fresh data from the database after modifications to ensure consistency.
Implement soft deletes at the infrastructure layer without polluting domain entities:
Domain Layer (Pure):
type Entity struct {
ID uuid.UUID
Name string
CreatedAt time.Time
UpdatedAt time.Time
// No DeletedAt field - keep domain pure
}
Infrastructure Layer (Database):
-- Database table includes deleted_at
CREATE TABLE entities (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL,
deleted_at TIMESTAMP WITH TIME ZONE -- Only in database
);
-- Delete operation becomes an UPDATE
-- name: DeleteEntity :exec
UPDATE entities SET deleted_at = NOW() WHERE id = $1;
-- All SELECT queries filter out soft-deleted records
-- name: GetEntityByID :one
SELECT id, name, created_at, updated_at
FROM entities
WHERE id = $1 AND deleted_at IS NULL;
Benefits:
Implementation Guidelines:
deleted_at column only in database schemadeleted_at = NOW()WHERE deleted_at IS NULL to all SELECT queries// Example for an e-commerce domain
type Order struct {
ID uuid.UUID
CreatedAt time.Time
UpdatedAt time.Time
CustomerID uuid.UUID
Items []OrderItem
Status OrderStatus
Total Money
}
func NewOrder(customerID uuid.UUID) *Order {
return &Order{
ID: uuid.New(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CustomerID: customerID,
Status: OrderStatusPending,
Items: []OrderItem{},
}
}
func (o *Order) AddItem(product Product, quantity int) error {
// Business logic for adding items
// Validate quantity, calculate prices, etc.
}
type OrderRepository interface {
Create(order *ValidatedOrder) (*Order, error)
FindByID(id uuid.UUID) (*Order, error)
FindByCustomerID(customerID uuid.UUID) ([]*Order, error)
Update(order *ValidatedOrder) (*Order, error)
}
Commands:
type PlaceOrderCommand struct {
IdempotencyKey string
CustomerID uuid.UUID
Items []OrderItemRequest
}
Queries:
type GetCustomerOrdersQuery struct {
CustomerID uuid.UUID
Status *OrderStatus // Optional filterfunc NewEntity(businessAttribute string) *Entity {
return &Entity{
ID: uuid.New(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
BusinessAttribute: businessAttribute,
}
}
}
type OrderService struct {
orderRepo repositories.OrderRepository
productRepo repositories.ProductRepository
idempotencyRepo repositories.IdempotencyRepository
}
func (s *OrderService) PlaceOrder(cmd *PlaceOrderCommand) (*PlaceOrderResult, error) {
// Implement idempotency check
// Validate products exist
// Create order
// Calculate totals
// Save order
// Return result
}
The patterns remain the same; only the domain concepts change.
These DDD and CQRS principles provide a robust foundation for building maintainable, scalable applications regardless of your business domain. The key is to:
By applying these principles, you create software that clearly expresses business requirements while remaining flexible for future changes.