What DDD Is — Starting From the Microsoft Ecosystem and the Repository Abstraction

During project development I encountered DDD (Domain-Driven Design) for the first time. I had previously worked with .NET MVC and also learned Go, and I realized many concepts are actually related to things I already knew — especially the Repository Pattern. This post documents how I used my existing knowledge to understand DDD.

The Request-Handler Approach

MVC stands for Model-View-Controller, three roles:

  • Model — data and business logic
  • View — presentation (HTML, JSON response, etc.)
  • Controller — receives user actions, calls the Model to process them, passes the result to the View for rendering
1
User action → Controller → Model → Controller → View → User sees result

Conceptually, the Controller is a middleman — it does not make logic decisions; it only coordinates the Model and the View.

Practical Architecture in the Microsoft Ecosystem

The textbook version of MVC has three layers, but in practice .NET MVC splits the Model further. Cramming business logic and database access together inside the Model becomes hard to maintain once the codebase grows, so the real-world split looks like this:

1
2
3
4
5
6
7
Controller  →  接 HTTP request, 決定呼叫哪個 Service, 回 response
Service     →  business logic 寫在這裡 (原本 Model 的 logic 部分)
Repository  →  資料庫存取 (原本 Model 的 data access 部分)
Entity      →  資料結構 (原本 Model 的 data 部分)

In an API scenario the View is just a JSON response, so it does not need much extra handling.

Take “activating a brand” as an example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Controller — receives HTTP request
[HttpPost("brands/{id}/activate")]
public IActionResult Activate(Guid id) {
    _brandService.Activate(id);
    return Ok();
}

// Service — all business logic lives here
public void Activate(Guid id) {
    var brand = _repo.GetById(id);
    if (brand.Status == BrandStatus.Archived)
        throw new InvalidOperationException("Cannot activate archived brand");
    brand.Status = BrandStatus.Active;
    _repo.Save(brand);
}

// Entity — just a data container, no logic
public class Brand {
    public Guid Id { get; set; }
    public string Name { get; set; }
    public BrandStatus Status { get; set; }
}

The Brand entity has only properties and no methods — it is just a data container. All the decision logic (“archived cannot activate”) lives in the Service. This kind of entity is called an anemic entity — it has data but no behavior.

Repository Pattern

The core idea behind the Repository Pattern:

Use an interface to abstract “how data is accessed,” so the Service does not need to know whether the underlying store is SQL Server, PostgreSQL, or in-memory.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// Interface — defines the contract
public interface IBrandRepository {
    Brand GetById(Guid id);
    void Save(Brand brand);
}

// Implementation — actual database access
public class SqlBrandRepository : IBrandRepository {
    private readonly DbContext _db;

    public Brand GetById(Guid id) {
        return _db.Brands.Find(id);
    }

    public void Save(Brand brand) {
        _db.Brands.Update(brand);
        _db.SaveChanges();
    }
}

Wire the interface to the implementation via DI (Dependency Injection):

1
2
// Startup.cs
services.AddScoped<IBrandRepository, SqlBrandRepository>();

The Service depends only on the IBrandRepository interface, not on the concrete SQL implementation. When you want to swap databases or write tests, just swap the implementation.

Once you understand this, DDD is not difficult — the Repository Pattern is already part of DDD.

What the Domain-Centric Method Adds

DDD stands for Domain-Driven Design. At first it sounded abstract, but once I grasped it, the core idea boils down to one thing:

Move business logic from the Service back onto the Entity itself.

Same “activate a brand” scenario, written the DDD way:

1
2
3
4
5
6
7
8
9
# Entity — has behavior, not just data
class Brand:
    name: str
    status: BrandStatus = BrandStatus.PENDING

    def activate(self):
        if self.status == BrandStatus.ARCHIVED:
            raise ValueError("Cannot activate archived brand")
        self.status = BrandStatus.ACTIVE
1
2
3
4
5
6
7
# Service — becomes thin, just orchestration
class BrandService:
    def activate(self, brand_id: UUID) -> Brand:
        brand = self._repo.find_by_id(brand_id)  # load
        brand.activate()                           # call domain method
        self._repo.save(brand)                     # save
        return brand

Side-by-side comparison:

.NET MVC DDD
Entity properties only (anemic) properties + methods (rich)
Service all decision logic here only load → call → save
Repository interface + implementation same concept

The Repository part is completely unchanged. The only difference is where the business rule lives.

Why Move It?

When 10 different Services all operate on Brand, the .NET MVC way scatters the “archived cannot activate” rule across those Services — it is easy for someone to miss one.

With DDD, that rule lives inside Brand.activate(), so no matter who calls it the check is enforced. The Entity protects its own state.

Core Concepts of the Domain-Centric Method

Identifiable Objects

Has a unique ID, has behavior, and changes over time.

1
2
3
4
5
6
7
class User:
    id: UUID
    display_name: str
    is_private: bool

    def make_private(self):
        self.is_private = True

The difference from a .NET Entity is the addition of methods. Two Entities are compared by ID, not by property values.

Value-Based Objects

No ID; compared by “value.” Immutable.

1
2
3
4
5
6
7
@dataclass(frozen=True)
class CommissionRate:
    basis_points: int  # e.g. 1500 = 15%

    def __post_init__(self):
        if not (0 <= self.basis_points <= 10_000):
            raise ValueError("Must be 0-10000 bps")

Analogy: two hundred-dollar bills — you do not care which specific bill, only the amount. That is a Value Object — compare by value, not by identity.

The closest equivalent in .NET is C# 9’s record, which defaults to value-based equality.

Consistency Clusters

This concept does not exist in .NET MVC, and it took me a while to understand it.

An Aggregate is a group of things that must always be accessed together, with a root entity as the entry point. Think of a Post and its Comment entries:

1
2
3
4
Post (aggregate root)
├── Comment 1
├── Comment 2
└── Comment 3

Rules:

  • Outside code can only touch the root (Post), not Comment directly
  • The entire aggregate is loaded and saved together
  • Different aggregates reference each other only by ID, never by holding the other’s object

The third point is the opposite of .NET EF Core’s navigation property. EF Core lets you write post.Author.Name to directly access the User object; DDD does not do this — it stores only author_id, and if you need the User you query it yourself.

The reason: if Post directly holds a User object, changing User means worrying about whether Post breaks. Using IDs to decouple means each aggregate manages only itself.

Domain Scope Demarcation

How do you decide what belongs in the same module?

DDD’s answer is to look at “language.” When the same word means different things in different scenarios, those are separate contexts.

Take “User” — for social features it means “a person with a follow list and profile”; for the wallet it means “an account with a balance and transaction history.” Although both are called User, the concerns are completely different, so the Users module and the Ledger module are two separate Bounded Contexts.

How our project splits them:

Context Concern
Users identity, profile, follow, curation
Brand brand data, Shopify integration
Ledger wallet, transactions, commission
Affiliate affiliate marketing, commission rate settings
Community posts, comments

How the Golang Ecosystem Handles It

When learning Go I was curious: does the Go community use DDD?

The answer is yes, but it is uncommon and the practice is looser. The Go community values simplicity and dislikes multi-layer abstractions, so a full DDD layering is rarely seen in Go projects. The standard practice is still handler → service → repository, similar to .NET MVC.

That said, a few DDD concepts come naturally in Go:

Go’s interfaces are implicit (no implements keyword), so defining a Repository contract is intuitive:

1
2
3
4
type BrandRepository interface {
    FindByID(ctx context.Context, id uuid.UUID) (*Brand, error)
    Save(ctx context.Context, brand *Brand) error
}

Entity with behavior uses a method on the struct:

1
2
3
4
5
6
7
func (b *Brand) Activate() error {
    if b.Status == StatusArchived {
        return errors.New("cannot activate archived brand")
    }
    b.Status = StatusActive
    return nil
}

Python’s Protocol (structural typing) works like Go’s implicit interface — you do not declare “I implement such-and-such interface”; if the method signatures match, it counts as implemented. This is quite different from C#, where you must write class Foo : IFoo explicitly.

Why Not Stick With the Three-Tier Pattern

I already know the Repository Pattern, so why bother with DDD?

Because as the project grew larger, we ran into several problems that the .NET MVC approach could not handle well:

  1. Services keep growing — all logic lives in Services; a single BrandService can be hundreds of lines, and changing one thing requires reading the entire file
  2. Rules scattered everywhere — the same business rule is written in multiple Services; fixing one and forgetting the others
  3. Modules tangled together — UserService imports BrandService, BrandService imports WalletService — a spider web
  4. No clear boundaries — wanting to split modules but not knowing where to cut

DDD’s solutions:

  • Problem 1 → entities carry their own logic, services become thin
  • Problem 2 → rules are written only on the entity, single source of truth
  • Problem 3 → cross-module wiring goes only through the presentation tier, no direct imports
  • Problem 4 → Bounded Contexts provide a criterion for where to cut modules

Summary

.NET MVC concept DDD equivalent What differs
Entity (property only) DDD Entity DDD entities have behavior
Repository Pattern DDD Repository Nearly the same; DDD is stricter (only returns the aggregate root)
Service Application Service DDD services are thinner — only load → call → save
Controller Presentation (route) Same concept
DI Container (Startup.cs) Composition Root (providers.py) Same concept, different location
No direct counterpart Value Object Immutable, compare by value
No direct counterpart Aggregate A group of things that must always be accessed together
No direct counterpart Bounded Context Use “language” to draw module boundaries

The next post will document the pitfalls I encountered in the actual project: drawing the wrong aggregate boundary, designing a double-entry ledger, and using an event system for cross-module communication.

References