Skip to main content

What is Domain-Driven Design?

Domain-Driven Design (DDD) is a software design approach that focuses on modeling software to match the business domain. The Blackjack API uses DDD tactical patterns to create a rich, expressive domain model.

DDD Building Blocks

Aggregates

Aggregates are clusters of domain objects that can be treated as a single unit. Each aggregate has a root entity that controls access to the entire aggregate.

Game Aggregate

Aggregate Root: Game Location: domain/model/Game.java:1-120
public final class Game {
    private final GameId id;
    private final PlayerId playerId;
    private final Deck deck;
    private final Hand playerHand;
    private final Hand dealerHand;
    private final GameStatus status;
    
    // Constructor is private - use factory methods
    private Game(GameId id, PlayerId playerId, Deck deck, 
                 Hand playerHand, Hand dealerHand, GameStatus status){
        this.id = Objects.requireNonNull(id);
        this.playerId = Objects.requireNonNull(playerId);
        this.deck = Objects.requireNonNull(deck);
        this.playerHand = Objects.requireNonNull(playerHand);
        this.dealerHand = Objects.requireNonNull(dealerHand);
        this.status = Objects.requireNonNull(status);
    }
    
    // Factory method for creating new games
    public static Game newGame(PlayerId playerId, Deck deck) {
        var d1 = deck.draw(); var c1 = d1.card(); deck = d1.nextDeck();
        var d2 = deck.draw(); var c2 = d2.card(); deck = d2.nextDeck();
        var d3 = deck.draw(); var c3 = d3.card(); deck = d3.nextDeck();
        var d4 = deck.draw(); var c4 = d4.card(); deck = d4.nextDeck();

        Hand player = Hand.empty().add(c1).add(c3);
        Hand dealer = Hand.empty().add(c2).add(c4);

        Game g = new Game(GameId.newId(), playerId, deck, player, dealer, GameStatus.IN_PROGRESS);
        return g.resolveNaturals();
    }
}
Game is an aggregate root because:
  1. Consistency Boundary: All blackjack rules (hit, stand, bust) must be enforced through the Game object
  2. Transaction Boundary: A game and its hands are saved/loaded as a single unit
  3. External Access: Other parts of the system reference games by GameId, never directly accessing Hand or Deck
  4. Invariant Protection: The game ensures all business rules are maintained (e.g., can’t hit after standing)

Game Business Logic

The aggregate root encapsulates all blackjack game logic: Location: domain/model/Game.java:55-68
public Game hit() {
    ensureInProgress();
    var draw = deck.draw();
    Hand nextPlayer = playerHand.add(draw.card());
    Game next = new Game(id, playerId, draw.nextDeck(), nextPlayer, dealerHand, status);
    
    if(nextPlayer.isBust()) return next.withStatus(GameStatus.PLAYER_BUST).finalizeOutcome();
    return next;
}

public Game stand() {
    ensureInProgress();
    Game afterDealer = dealerPlay();
    return afterDealer.finalizeOutcome();
}
Immutability: Notice that hit() and stand() return new Game instances rather than modifying the existing one. This prevents bugs and makes the code easier to reason about.

Player Aggregate

Aggregate Root: Player Location: domain/model/Player.java:1-40
public final class Player {
    private final PlayerId id;
    private final PlayerName name;
    private final int wins;
    private final int losses;

    public Player(PlayerId id, PlayerName name, int wins, int losses) {
        this.id = Objects.requireNonNull(id, "id");
        this.name = Objects.requireNonNull(name, "name");
        if (wins < 0 || losses < 0) 
            throw new IllegalArgumentException("wins/losses cannot be negative");
        this.wins = wins;
        this.losses = losses;
    }

    public static Player newPlayer(PlayerName name) {
        return new Player(PlayerId.newId(), name, 0, 0);
    }

    public Player rename(PlayerName newName) {
        return new Player(id, newName, wins, losses);
    }

    public Player registerWin() {
        return new Player(id, name, wins + 1, losses);
    }

    public Player registerLoss() {
        return new Player(id, name, wins, losses + 1);
    }
    
    public int score() { 
        return wins - losses; 
    }
}
Player is a separate aggregate because:
  • It has a different lifecycle than games (players persist across multiple games)
  • It has its own invariants (name validation, non-negative stats)
  • It can be referenced from games via PlayerId

Entities

Entities are objects with a distinct identity that persists over time, even if attributes change.

Characteristics of Entities

PropertyDescriptionExample
IdentityUnique identifierGameId, PlayerId
Mutable (conceptually)Attributes can changePlayer’s name or stats can change
Equality by IDTwo entities are equal if IDs matchTwo Player objects with same PlayerId

Hand Entity

Location: domain/model/Hand.java:1-46
public class Hand {
    private final List<Card> cards;

    private Hand(List<Card> cards) {
        this.cards = List.copyOf(cards);
    }

    public static Hand empty() { 
        return new Hand(List.of()); 
    }

    public Hand add(Card card) {
        var next = new ArrayList<>(cards);
        next.add(card);
        return new Hand(next);
    }

    public int score() {
        int total = 0;
        int aces = 0;

        for (Card c: cards) {
            total += c.rank().getDefaultValue();
            if (c.rank().isAce()) aces++;
        }

        while (total > 21 && aces > 0) {
            total -= 10;
            aces--;
        }
        return total;
    }

    public boolean isBlackjack() { 
        return cards.size() == 2 && score() == 21; 
    }
    
    public boolean isBust() { 
        return score() > 21; 
    }
}
Hand implements blackjack-specific logic like:
  • Ace value adjustment (11 or 1)
  • Blackjack detection (21 with exactly 2 cards)
  • Bust detection (score > 21)

Value Objects

Value Objects are immutable objects defined by their attributes rather than identity. Two value objects with the same attributes are considered equal.

Card Value Object

Location: domain/model/Card.java:1-10
public record Card(Rank rank, Suit suit) {
    public Card {
        Objects.requireNonNull(rank, "rank");
        Objects.requireNonNull(suit, "suit");
    }
}
Using Java records for value objects provides:
  • Immutability by default
  • Automatic equals() and hashCode() based on attributes
  • Concise syntax
  • Value-based semantics

GameId Value Object

Location: domain/model/GameId.java:1-12
public record GameId(String value) {
    public GameId {
        if (value == null || value.isBlank()) 
            throw new IllegalArgumentException("GameId cannot be blank");
    }
    
    public static GameId newId() { 
        return new GameId(UUID.randomUUID().toString()); 
    }
    
    @Override
    public String toString() { 
        return value; 
    }
}

PlayerName Value Object

Location: domain/model/PlayerName.java:1-11
public record PlayerName(String value) {
    public PlayerName {
        if (value == null) 
            throw new IllegalArgumentException("PlayerName cannot be null");
        String v = value.trim();
        if (v.isEmpty()) 
            throw new IllegalArgumentException("PlayerName cannot be blank");
        if (v.length() > 30) 
            throw new IllegalArgumentException("PlayerName too long (max 30)");
        value = v;  // Use normalized value
    }
}
Value objects encapsulate validation logic, ensuring invalid states are impossible. You cannot create a PlayerName that’s blank or too long.

Benefits of Value Objects

Type Safety

Can’t accidentally pass a GameId where a PlayerId is expected.

Self-Validating

Validation logic lives with the value object, not scattered throughout the codebase.

Immutability

Value objects cannot be modified after creation, preventing bugs.

Domain Expressiveness

PlayerName is more meaningful than String.

Domain Services

Domain services contain domain logic that doesn’t naturally belong to any entity or value object.
The Blackjack API currently doesn’t have explicit domain services because all domain logic fits naturally within aggregates (Game and Player).If we needed to implement a match-making service or tournament logic that coordinates multiple games and players, those would be domain services.

When to Create a Domain Service

Create a domain service when:
  • Operation involves multiple aggregates
  • Logic doesn’t naturally belong to a single entity
  • Operation is a significant domain concept (e.g., “CalculatePlayerRanking”)

Domain Events (Implicit)

Although not explicitly implemented, the domain model has implicit events:
// When a game finishes, player stats are updated
public Mono<Game> persistAndUpdateStats(Game updated) {
    return gameRepo.save(updated)
            .flatMap(saved -> {
                if (saved.status() != GameStatus.IN_PROGRESS) {
                    // Implicit domain event: GameFinished
                    return updatePlayerStatsIfFinished(saved)
                            .thenReturn(saved);
                }
                return Mono.just(saved);
            });
}
In a more event-driven architecture, this could be refactored to:
  1. Game.stand() publishes GameFinished event
  2. Event handler updates player statistics
  3. Decouples game logic from player statistics

Ubiquitous Language

DDD emphasizes using domain terminology consistently across code and conversations.

Blackjack Ubiquitous Language

Domain TermCode RepresentationMeaning
GameGame aggregateA single blackjack game session
PlayerPlayer aggregateA registered player
HandHand entityCards held by player or dealer
HitGame.hit()Draw another card
StandGame.stand()End turn, dealer plays
BustHand.isBust()Score exceeds 21
BlackjackHand.isBlackjack()Natural 21 with 2 cards
DeckDeck entity52-card shoe
Notice how the code uses the same terms as domain experts would use when discussing blackjack.

Invariant Protection

Aggregates enforce business rules (invariants) to maintain consistency.

Example: Game State Transitions

Location: domain/model/Game.java:110-112
private void ensureInProgress() {
    if (status != GameStatus.IN_PROGRESS) 
        throw new InvalidMoveException("Game is not in progress");
}
This prevents:
  • Hitting after the game has ended
  • Standing in an already-finished game
  • Invalid state transitions

Example: Player Stats Validation

Location: domain/model/Player.java:14
if (wins < 0 || losses < 0) 
    throw new IllegalArgumentException("wins/losses cannot be negative");
Invariants should be enforced at construction time and through controlled mutations (like registerWin()) to ensure the domain model never enters an invalid state.

Anemic vs. Rich Domain Model

Anemic Domain Model (Anti-pattern)

// Bad: Domain objects are just data containers
public class Game {
    public String id;
    public String playerId;
    public List<Card> playerCards;
    public List<Card> dealerCards;
    public String status;
    
    // No behavior!
}

// Logic lives in a service
public class GameService {
    public void hit(Game game) {
        // Business logic here, manipulating game state
    }
}

Rich Domain Model (DDD Approach)

// Good: Domain objects encapsulate behavior
public final class Game {
    private final GameId id;
    private final Hand playerHand;
    private final Hand dealerHand;
    private final GameStatus status;
    
    // Business logic is inside the domain object
    public Game hit() {
        ensureInProgress();
        var draw = deck.draw();
        Hand nextPlayer = playerHand.add(draw.card());
        Game next = new Game(id, playerId, draw.nextDeck(), 
                             nextPlayer, dealerHand, status);
        
        if(nextPlayer.isBust()) 
            return next.withStatus(GameStatus.PLAYER_BUST)
                      .finalizeOutcome();
        return next;
    }
}
In the Blackjack API, domain logic lives in domain objects, not in services. This makes the code more maintainable and the domain model more expressive.

Persistence Ignorance

Domain models are pure Java with no persistence framework annotations. Domain Model (domain/model/Game.java):
public final class Game {
    private final GameId id;
    private final PlayerId playerId;
    private final Deck deck;
    // No @Entity, @Document, or persistence annotations!
}
Persistence Model (infrastructure/persistence/mongo/MongoGameDocument.java):
@Document(collection = "games")
public class MongoGameDocument {
    @Id
    private String id;
    private String playerId;
    private List<CardDoc> deckCards;
    // Persistence-specific annotations
}
Separating domain models from persistence models:
  • Keeps domain pure and framework-independent
  • Allows different persistence strategies (MongoDB for games, MySQL for players)
  • Makes testing easier (no mocking of persistence frameworks)

Factory Methods

Domain objects use factory methods to control object creation.
// Game creation
public static Game newGame(PlayerId playerId) {
    return newGame(playerId, Deck.standardShuffled());
}

// Player creation
public static Player newPlayer(PlayerName name) {
    return new Player(PlayerId.newId(), name, 0, 0);
}

// GameId creation
public static GameId newId() {
    return new GameId(UUID.randomUUID().toString());
}
Benefits:
  • Meaningful names: Game.newGame() vs new Game(...)
  • Encapsulation: Constructor is private, forcing use of factory
  • Flexibility: Factory can apply business rules or defaults

DDD in Use Cases

Use cases orchestrate domain objects: Location: application/usecase/PlayMoveUseCase.java:24-38
public Mono<GameStateResult> execute(PlayMoveCommand cmd) {
    MoveAction action = MoveAction.valueOf(cmd.action().toUpperCase());

    return gameRepo.findById(new GameId(cmd.gameId()))
            .switchIfEmpty(Mono.error(new GameNotFoundException(cmd.gameId())))
            .map(game -> applyMove(game, action))  // Domain logic!
            .flatMap(this::persistAndUpdateStats)
            .map(mapper::toResultForPlayer);
}

private Game applyMove(Game game, MoveAction action) {
    return switch (action) {
        case HIT -> game.hit();      // Delegates to domain
        case STAND -> game.stand();  // Delegates to domain
    };
}
Use cases:
  1. Load domain objects from repositories
  2. Delegate business logic to domain objects
  3. Save updated domain objects
  4. Map to result DTOs

Summary: DDD Patterns in Blackjack API

PatternImplementationLocation
Aggregate RootGame, Playerdomain/model/
EntityHand, Deckdomain/model/
Value ObjectCard, GameId, PlayerNamedomain/model/
Factory MethodGame.newGame(), Player.newPlayer()domain/model/
Invariant ProtectionensureInProgress(), constructor validationThroughout domain
Ubiquitous LanguageDomain terms in code match business termsEverywhere
Rich Domain ModelBusiness logic in domain objectsdomain/model/
Persistence IgnoranceDomain models have no persistence annotationsdomain/model/

Best Practices

Domain models should have no dependencies on frameworks or infrastructure. They’re just plain Java.
Use constructors and factory methods to ensure objects are always in a valid state.
Make domain objects immutable where possible. Return new instances instead of modifying state.
Business rules should be explicit in domain code, not hidden in service layers or database triggers.
Wrap primitive types in value objects for type safety and validation.

Next Steps

Reactive Programming

Learn how domain operations are made reactive

Hexagonal Architecture

See how DDD integrates with ports and adapters

Build docs developers (and LLMs) love