DDD (Domain-Driven Design): co to jest i jak działa na przykładzie w Pythonie

Mateusz Bogolubow

30-10-2026

(aktualizacja: 30-10-2026)

DDD to sposób projektowania oprogramowania wokół dziedziny biznesowej. Poznaj język wspólny, konteksty, encje, agregaty i przykład w Pythonie, który możesz uruchomić.

DDD (Domain-Driven Design): co to jest i jak działa na przykładzie w Pythonie
DDD (Domain-Driven Design) to sposób projektowania oprogramowania wokół dziedziny biznesowej: wspólny słownik programistów i ekspertów trafia bezpośrednio do kodu, a duży system dzieli się na konteksty z własnymi modelami. Najważniejsze elementy to język wspólny, kontekst ograniczony, encje, obiekty wartości i agregaty, które pilnują reguł biznesowych. DDD opłaca się przy złożonych domenach, a w prostych aplikacjach bywa przesadą.

Programista widzi w tabeli kolumnę status = 3, a księgowa mówi o „zamówieniu opłaconym”. Obie osoby mają na myśli to samo, ale nie wiedzą tego i każda z nich używa innych słów. DDD zaczyna się właśnie tu: od zbliżenia języka kodu do języka biznesu. Poniżej wyjaśniam, czym jest, jakie ma elementy i jak wyglądają one w działającym kodzie Pythona.

Czym jest DDD?

DDD (Domain-Driven Design, czyli projektowanie sterowane dziedziną) to podejście opisane w książce Erica Evansa z 2003 roku. Według Martina Fowlera jego sednem jest programowanie modelu domeny, który „rozumie procesy i reguły” danej dziedziny, a model żyje w kodzie i rozwija się razem z projektem, a nie zostaje na papierze. Dziedzina (domain) to obszar, który program obsługuje: sprzedaż, logistyka, rezerwacje, płatności. Czasem spotkasz też nazwę „Domain-Driven Development”, ale poprawne rozwinięcie skrótu to Design: Evans opisał DDD jako sposób projektowania, a nie samego programowania.

DDD składa się z dwóch części:

  • Projektowanie strategiczne: jak podzielić duży system na części i jak ustalić wspólny język (język wspólny, konteksty).
  • Projektowanie taktyczne: z jakich klocków budować model w kodzie (encje, obiekty wartości, agregaty, repozytoria).

Kiedy DDD ma sens, a kiedy jest przesadą?

Fowler pisze, że DDD jest szczególnie przydatne w złożonych domenach, gdzie jest dużo często nieuporządkowanej logiki biznesowej. Dla prostej aplikacji, która głównie zapisuje i odczytuje dane z formularza (CRUD), pełne DDD zwykle nie jest potrzebne i dodaje tylko warstwy abstrakcji.

SytuacjaCzy DDD ma sens?
Sklep z wieloma regułami (promocje, zwroty, magazyn, płatności)Tak
System, nad którym pracuje kilka zespołówTak, szczególnie konteksty
Prosty formularz kontaktowy lub blogNie
Prototyp, który ma żyć tydzieńNie
Aplikacja, której reguły biznesowe często się zmieniająTak, bo reguły mają jedno miejsce w kodzie

Czym jest język wspólny (ubiquitous language)?

Język wspólny (ubiquitous language) to słownik pojęć, którego używają wszyscy: programiści, analitycy, osoby z biznesu. Te same słowa pojawiają się w rozmowach, dokumentacji i nazwach klas oraz metod. Jeśli biznes mówi „potwierdzić zamówienie”, w kodzie powinna być metoda confirm() w klasie Order, a nie setStatus(3).

Dlaczego to ważne? Bo każde tłumaczenie między „językiem biznesu” a „językiem kodu” to miejsce na błąd. Im mniej takich tłumaczeń, tym mniej nieporozumień i tym łatwiej nowej osobie zrozumieć kod.

Czym jest kontekst ograniczony (bounded context)?

Ten sam wyraz może znaczyć co innego w różnych częściach firmy. „Klient” w dziale sprzedaży to ktoś, kto płaci i ma limit kredytowy, a „klient” w dziale dostaw to odbiorca paczki z adresem i telefonem. Próba zbudowania jednej uniwersalnej klasy Customer kończy się klasą z dziesiątkami pól, z których każdy zespół używa połowy.

Kontekst ograniczony (bounded context) rozwiązuje to przez podział dużej dziedziny na części, w których każde pojęcie ma jedno, spójne znaczenie. Fowler ujmuje to tak: potrzebujesz innego modelu, gdy zmienia się język. Wygląda to tak:

from dataclasses import dataclass


# kontekst „Sprzedaż”: klient to ktoś, kto płaci
@dataclass
class SalesCustomer:
    id: str
    credit_limit: int


# kontekst „Dostawa”: klient to odbiorca paczki
@dataclass
class ShippingCustomer:
    id: str
    address: str
    phone: str


print(SalesCustomer("c-1", 5000))
print(ShippingCustomer("c-1", "ul. Długa 1, Gdańsk", "+48 600 000 000"))

Wynik:

SalesCustomer(id='c-1', credit_limit=5000)
ShippingCustomer(id='c-1', address='ul. Długa 1, Gdańsk', phone='+48 600 000 000')

Obie klasy opisują „klienta” o tym samym identyfikatorze, ale każda zawiera tylko to, co jest ważne w jej kontekście. Konteksty komunikują się przez jawne mapowanie (np. przekazanie id), a nie przez wspólną klasę.

Jakie elementy składają się na model domeny?

ElementCo to jestPrzykład
Encja (entity)Obiekt z tożsamością, która trwa mimo zmiany jego danychOrder z numerem ord-1: nawet gdy zmienią się pozycje, to wciąż to samo zamówienie
Obiekt wartości (value object)Obiekt bez tożsamości, definiowany przez swoje wartości, niezmiennyMoney(10, "PLN"): dwa takie obiekty o tej samej kwocie są równe
Agregat (aggregate)Grupa powiązanych obiektów traktowana jak jedna całość, z jednym korzeniemZamówienie razem z pozycjami
Repozytorium (repository)Obiekt, który zapisuje i wczytuje agregaty, ukrywając bazęOrderRepository.get("ord-1")
Serwis domenowyLogika, która nie pasuje do jednej encjiWyliczenie kosztu dostawy z kilku zamówień

Jak wygląda agregat w kodzie Pythona?

Zamówienie i jego pozycje to klasyczny przykład agregatu. Fowler zaznacza, że z zewnątrz komunikujesz się wyłącznie z korzeniem agregatu, a ten pilnuje spójności całości. Poniżej uruchamialny przykład z obiektami wartości (Money, OrderLine) i korzeniem agregatu (Order):

from dataclasses import dataclass
from decimal import Decimal


@dataclass(frozen=True)
class Money:
    """Obiekt wartości: równość po wartości, niezmienny."""
    amount: Decimal
    currency: str = "PLN"

    def __add__(self, other: "Money") -> "Money":
        if self.currency != other.currency:
            raise ValueError("Nie można dodawać różnych walut")
        return Money(self.amount + other.amount, self.currency)


@dataclass(frozen=True)
class OrderLine:
    product_id: str
    quantity: int
    unit_price: Money

    def total(self) -> Money:
        return Money(self.unit_price.amount * self.quantity, self.unit_price.currency)


class Order:
    """Korzeń agregatu: tylko przez niego zmieniamy pozycje zamówienia."""

    def __init__(self, order_id: str):
        self.id = order_id            # tożsamość encji
        self._lines: list[OrderLine] = []
        self._confirmed = False

    def add_line(self, product_id: str, quantity: int, unit_price: Money) -> None:
        if self._confirmed:
            raise ValueError("Zamówienie jest już potwierdzone")
        if quantity <= 0:
            raise ValueError("Ilość musi być dodatnia")
        self._lines.append(OrderLine(product_id, quantity, unit_price))

    def total(self) -> Money:
        total = Money(Decimal("0"))
        for line in self._lines:
            total = total + line.total()
        return total

    def confirm(self) -> None:
        if not self._lines:
            raise ValueError("Nie można potwierdzić pustego zamówienia")
        self._confirmed = True

Sprawdźmy, jak się zachowuje:

print(Money(Decimal("10")) == Money(Decimal("10")))      # obiekt wartości: True

order = Order("ord-1")
order.add_line("book", 2, Money(Decimal("30")))
order.add_line("pen", 1, Money(Decimal("5")))
print(order.total())

order.confirm()
for action, fn in [
    ("dodanie po potwierdzeniu", lambda: order.add_line("bag", 1, Money(Decimal("90")))),
    ("potwierdzenie pustego", lambda: Order("ord-2").confirm()),
    ("ujemna ilość", lambda: Order("ord-3").add_line("pen", -1, Money(Decimal("5")))),
]:
    try:
        fn()
    except ValueError as e:
        print(f"{action}: {e}")

Wynik uruchomienia (Python 3.13):

True
Money(amount=Decimal('65'), currency='PLN')
dodanie po potwierdzeniu: Zamówienie jest już potwierdzone
potwierdzenie pustego: Nie można potwierdzić pustego zamówienia
ujemna ilość: Ilość musi być dodatnia

Co tu widać:

  • Obiekt wartości Money jest równy innemu Money o tej samej kwocie, bo liczą się wartości, a nie tożsamość.
  • Reguły biznesowe („nie można dodać pozycji po potwierdzeniu”, „zamówienie bez pozycji nie może być potwierdzone”) są w jednym miejscu, w korzeniu agregatu. Nie da się ich ominąć, bo pozycje są prywatne.
  • Metody mają nazwy z języka biznesu: confirm(), add_line().

Czym jest niezmiennik i dlaczego agregat go pilnuje?

Niezmiennik (invariant) to reguła, która zawsze musi być prawdziwa, np. „potwierdzone zamówienie nie zmienia pozycji”. Agregat istnieje po to, żeby ją gwarantować. Dlatego praktyczne pytanie przy projektowaniu brzmi: „jaka reguła musi być zawsze spełniona i które obiekty są do jej sprawdzenia potrzebne?”. Te obiekty tworzą agregat. Fowler dodaje drugą zasadę: transakcja nie powinna przekraczać granic agregatu, czyli jedna operacja zapisuje jeden agregat.

Czym jest repozytorium i po co ono jest?

Repozytorium (repository) to obiekt, który zapisuje i wczytuje agregaty, ukrywając szczegóły bazy danych. Działa jak kolekcja zamówień: prosisz o zamówienie o danym numerze i dostajesz gotowy obiekt Order, a nie wiersze tabeli. Logika domeny zna tylko interfejs repozytorium, więc możesz podmienić bazę bez zmiany reguł, a w testach użyć wersji w pamięci. Przykład (korzysta z klas Money i Order z poprzedniej sekcji):

from abc import ABC, abstractmethod


class OrderRepository(ABC):
    """Interfejs repozytorium: domena zna tylko to, a nie bazę danych."""

    @abstractmethod
    def get(self, order_id: str) -> Order | None: ...

    @abstractmethod
    def save(self, order: Order) -> None: ...


class InMemoryOrderRepository(OrderRepository):
    """Wersja w pamięci: dobra do testów. Wersja z bazą miałaby ten sam interfejs."""

    def __init__(self):
        self._orders: dict[str, Order] = {}

    def get(self, order_id: str) -> Order | None:
        return self._orders.get(order_id)

    def save(self, order: Order) -> None:
        self._orders[order.id] = order


def confirm_order(repo: OrderRepository, order_id: str) -> None:
    """Przypadek użycia: wczytaj agregat, wywołaj regułę, zapisz agregat."""
    order = repo.get(order_id)
    if order is None:
        raise LookupError(f"Brak zamówienia {order_id}")
    order.confirm()
    repo.save(order)


repo = InMemoryOrderRepository()
order = Order("ord-1")
order.add_line("book", 2, Money(Decimal("30")))
repo.save(order)

confirm_order(repo, "ord-1")
print(repo.get("ord-1").total())

try:
    confirm_order(repo, "ord-404")
except LookupError as e:
    print(e)

Wynik uruchomienia:

Money(amount=Decimal('60'), currency='PLN')
Brak zamówienia ord-404

Funkcja confirm_order nie wie, czy zamówienia leżą w pamięci, w SQLite czy w PostgreSQL. Zmienia się tylko klasa repozytorium, a reguła confirm() zostaje w agregacie. Dzięki temu możesz też napisać szybkie testy bez bazy danych.

Jak wygląda walidacja w DDD?

Częste zamieszanie dotyczy tego, gdzie sprawdzać dane. Pomaga podział na dwie warstwy:

  • Na brzegu aplikacji (formularz, API) sprawdzasz kształt danych: czy to poprawny e-mail, czy liczba jest liczbą. Do tego dobrze służy Pydantic.
  • W modelu domeny (w agregacie) sprawdzasz reguły biznesowe: czy zamówienie można potwierdzić, czy ilość ma sens w tym stanie zamówienia.

Dzięki temu reguły biznesowe obowiązują także wtedy, gdy kod wywołasz nie z formularza, tylko z zadania w tle czy testu.

Czy model domeny musi być taki sam jak model bazy danych?

Nie musi. Model domeny opisuje reguły i zachowanie, a model bazy (tabele, relacje) opisuje sposób przechowywania. W prostych projektach klasa ORM i klasa domeny bywają tą samą klasą, a w złożonych oddziela się je i mapuje przez repozytorium. Jak zbudować modele i sesję w SQLAlchemy, opisuję w artykule o SQLAlchemy 2 w FastAPI. Dobry początek to jeden model, a rozdzielenie go dopiero wtedy, gdy zaczyna ci przeszkadzać.

Jak DDD pomaga w pracy z agentem AI?

Agent AI dostaje to, co mu opiszesz. Jeśli w specyfikacji raz piszesz „klient”, raz „użytkownik”, a raz „subskrybent”, agent zgadnie, co jest czym. Wspólny słownik z DDD usuwa tę niepewność: te same terminy w specyfikacji, nazwach klas i testach. Ułatwia to też dzielenie dużego projektu na kilka mniejszych zadań, bo każdy kontekst może mieć własną specyfikację. O pracy ze specyfikacją piszę w artykule o spec-driven development, a o narzędziach agentowych w porównaniu narzędzi AI dla programistów.

Od czego zacząć z DDD w swoim projekcie?

Zacznij od słownika, a nie od klas. Zanim napiszesz pierwszą klasę, zapisz około dziesięciu pojęć swojej dziedziny własnymi słowami, po jednym zdaniu każde. Jeśli dwa pojęcia brzmią podobnie, a znaczą co innego, albo to samo słowo znaczy co innego w dwóch miejscach, masz kandydata na dwa osobne konteksty. To godzina pracy, która oszczędza dużo późniejszej refaktoryzacji. Dopiero po słowniku przechodzisz do encji, obiektów wartości i agregatów.

Gdzie posłuchać o DDD w praktyce?

O tym, jak DDD wygląda w rzeczywistych projektach i co oznacza ten akronim w codziennej pracy, rozmawiałem z Magdaleną Limanówką-Kuciel. Nagranie jest z 2020 roku, ale podstawy się nie zmieniły:

Podsumowanie: czym jest DDD?

  • DDD to projektowanie wokół dziedziny: wspólny język biznesu i kodu oraz podział dużego systemu na konteksty.
  • Klocki taktyczne: encje (mają tożsamość), obiekty wartości (liczy się wartość), agregaty (pilnują reguł) i repozytoria (zapis i odczyt agregatów).
  • Reguły biznesowe mają mieć jedno miejsce w kodzie, czyli w korzeniu agregatu.
  • Repozytorium ukrywa bazę danych, więc możesz ją podmienić bez zmiany reguł.
  • Zacznij od słownika pojęć dziedziny, dopiero potem projektuj klasy.
  • Walidację kształtu danych rób na brzegu, a reguły biznesowe w modelu domeny.
  • DDD opłaca się w złożonych domenach, a w prostych aplikacjach bywa przesadą.
  • Wspólny słownik z DDD poprawia także specyfikacje dla agentów AI.
Konsultacja i warsztaty devmentor.pl

Masz pytania?

Partnerzy

Firmy, z którymi współpracujemy

To partnerzy odpowiedzialni za staże po naszych kursach. Organizują je dla najlepszych absolwentów.

Zobacz wszystkich partnerów →
  • VR Medical
  • Sophona
  • Robot Force