General Introduction to Domain Driven Design

General Introduction to Domain Driven Design

11 May 2025·Alysson Ribeiro
Alysson Ribeiro

In this post, you will learn about the foundational principles and goals of Domain Driven Design (DDD). The concepts and approaches described are heavily biased towards my own experience with DDD, reflecting my particular interpretation. DDD promotes a layered architecture (e.g., Domain, Application, Infrastructure, Presentation/UI). While the core business logic resides in the Domain Layer.

What is Domain Driven Design?

In my own words (without directly quoting external sources), DDD is the methodology of focusing on business processes more than on their technical counterparts. Usually, when you start programming a new application, the main spotlight is always on the data and how to structure it. With DDD, you first want to model what processes are defined, and what these processes entail. So, you always start DDD not focused on code, but on how your entities will behave.

The domain is the central piece; it’s not reliant on any technology or technical concepts. It doesn’t know databases; it doesn’t know transactions. To have a clean domain, you need to define a non-technical vocabulary for your “Ubiquitous Language.”

Example of a very simple domain for a financial ledger:

Example

(owner)

(member)

(contains)

User

+ Attributes

+CreateLedger(...) : Ledger

Ledger

+ Attributes

+AddExpense(...) : Expense

+AddMember(User)

Expense

+ Attributes

Domain

So let’s write a domain layer for this example:

package domain

type (
    User struct {
        ID int
    }

    Expense struct {
        ID int
        LedgerID int
    }

    Ledger struct {
        ID int
        Owner int
        Members []int
        Expenses []Expense
    }
)

func NewUser(id int) User {
    return User{ ID: id }
}

func (u User) CreateLedger() Ledger {
    return Ledger{
        Owner: u.ID,
        Members: []LedgerMember{u.ID},
    }
}

func (l *Ledger) AddExpense() Expense {
    expense := Expense{
        ID: 1,
        LedgerID: l.ID,
    }

    l.Expenses = append(l.Expenses, expense)

    return expense
}

func (l *Ledger) AddMember(u User) {
    l.Members = append(l.Members, u.ID)
}

In this example, all our modeled processes occur in memory; we have a state machine controlling all possible transitions. This is initially trivial when the cardinality of your entities is not high. But if you start to consider, for example, that the Ledger might have millions of entries, you might want to decouple the Ledger from the Expenses.

Example for high cardinality

Let’s imagine an example where the ledger still has some control over expenses, while not loading millions of rows into memory.

// previous code

type Ledger struct {
    ID int
    Owner int
    Members []LedgerMember
    ExpensesCount int
}

const MaxLedgerExpenses = 100_000_000

func (l *Ledger) AddExpense() (*Expense, error) {
    if l.ExpensesCount+1 > MaxLedgerExpenses {
        return nil, fmt.Errorf("expense count exceeded")
    }

    expense := &Expense{
        ID: 1,
        LedgerID: l.ID,
    }

    l.ExpensesCount++

    return expense, nil
}

// further code

With this change, now you don’t have to load expenses into memory, and can selectively control any given expenses from the domain. There is only one potential problem:

  • How do we keep transactional consistency when an operation spans multiple objects or updates an aggregate and its related data?

Application and Transactionality

Now, moving further into our application, we consider that we have the previous domain implemented. The Ledger entity acts as a controller for the expenses, and it has a count for its expenses.

If we consider that multiple expenses can be created in parallel, we want to avoid loading dirty states or having race conditions on these entities. What I normally do is use the ledger as a lock, inside a database transaction.

Postgres lock example

If you consider PostgreSQL, for example, you can lock the ledger row with:

SELECT * FROM ledgers WHERE id = $1 FOR UPDATE;

Bringing the transaction into the application layer, you ensure that there are no other queries mutating the state while the domain operations are happening. Let’s introduce a transaction in the Application layer:

package application

type (
    Database interface {
        Transaction(context.Context, func(Database) error) error
        LedgerRepository() LedgerRepository
        ExpenseRepository() ExpenseRepository
    }

    LedgerRepository interface {
        Load(context.Context, int) *domain.Ledger
        Save(context.Context, *domain.Ledger)
    }

    ExpenseRepository interface {
        Load(context.Context, int) *domain.Expense
        Save(context.Context, *domain.Expense)
    }
)

// Application definition...

func (a Application) CreateExpense(ctx context.Context, req CreateExpenseRequest) error {
    return a.db.Transaction(ctx, func(db Database) error {
        ledger := db.LedgerRepository().Load(ctx, req.LedgerID)

        expense, err := ledger.AddExpense()
        if err != nil {
            return err
        }

        if err := db.LedgerRepository().Save(ctx, ledger); err != nil {
            return err
        }

        return db.ExpenseRepository().Save(ctx, expense)
    })
}

With this approach, you have transactionality, meaning you either complete the domain operations or none of them are applied (they are rolled back), while fully controlling the state from the domain layer. The application layer is still unaware of database-specific infrastructure, only having the concept of a transactional database.

Conclusion

Let me know your thoughts; send me an email and let’s talk.