DDD

Domain-Driven Design

DDD architecture

Domain-Driven Design

Definition

Domain-Driven Design (DDD) is a software design methodology, articulated by Eric Evans in his 2003 book of the same name, that places the core business domain at the center of architectural decisions. It emphasizes a ubiquitous language shared between developers and domain experts, strategic decomposition into bounded contexts, and tactical patterns like aggregates, entities, value objects, and domain events to model complex business logic faithfully. DDD is not a technology or a framework -- it is a way of thinking about software that prioritizes understanding the problem domain before choosing implementation strategies.

The methodology operates at two levels. Strategic DDD addresses the large-scale structure: how to decompose a complex domain into manageable bounded contexts, how those contexts communicate, and where to invest modeling effort. Tactical DDD provides patterns for implementing domain logic within a bounded context: aggregates enforce transactional consistency boundaries, entities carry identity through state changes, value objects represent measurements or descriptions without identity, and domain events capture significant occurrences. The strategic level determines system architecture; the tactical level determines code structure within each component.

The Prismatic Platform's umbrella architecture directly reflects DDD principles. Each of the 115+ Elixir apps represents a bounded context with explicit boundaries: prismatic_perimeter owns the EASM domain, prismatic_safety owns quality enforcement, prismatic_agents owns the agent lifecycle, and prismatic_storage_core provides the shared kernel of traits and contracts. Cross-context communication uses well-defined protocols and behaviours rather than shared mutable state, enforcing aggregate boundaries at the OTP process level. This alignment between DDD concepts and Elixir/OTP constructs is not coincidental -- both traditions prioritize explicit boundaries, message-based communication, and isolated state.

Historical Context and Evolution

Eric Evans published "Domain-Driven Design: Tackling Complexity in the Heart of Software" in 2003, synthesizing patterns he observed in successful enterprise software projects. The book introduced the idea that software teams should invest heavily in understanding the problem domain before writing code, and that the resulting domain model should drive every architectural decision.

DDD gained renewed relevance with the rise of microservices architecture around 2014. The bounded context concept provided a principled approach to service decomposition -- instead of arbitrary microservice boundaries, teams could use domain analysis to identify natural seams. Vaughn Vernon's "Implementing Domain-Driven Design" (2013) bridged the gap between Evans's conceptual framework and practical implementation. Scott Wlaschin's "Domain Modeling Made Functional" (2018) demonstrated that DDD patterns are not inherently object-oriented and translate naturally to functional programming languages, which is particularly relevant for the Prismatic Platform's Elixir-based implementation.

The evolution from monolithic to distributed architectures has made DDD's strategic patterns more important than ever. In a monolith, poor domain boundaries create tangled code; in a distributed system, they create tangled services with cascading failures, performance problems, and deployment coupling. Getting the boundaries right is the single most impactful architectural decision in a distributed system.

Strategic Patterns

Bounded Contexts

The bounded context is the primary strategic pattern in DDD. It defines an explicit boundary within which a domain model is internally consistent. Different bounded contexts may use the same terminology with different meanings -- "asset" in prismatic_perimeter means an external attack surface element, while in prismatic_storage_core it means a storable entity.

Prismatic AppBounded ContextCore Domain Concept
prismatic_perimeterExternal Attack Surface ManagementAssets, security ratings, compliance
prismatic_agentsAgent Lifecycle ManagementAgents, tiers, capabilities, missions
prismatic_safetyQuality EnforcementQuality gates, violations, healing cycles
prismatic_storage_coreStorage ContractsTraits, protocols, behaviours
prismatic_claudeSession IntelligenceStack frames, conversation state, session lifecycle
prismatic_webUser InterfaceLiveView pages, components, routes
prismatic_apiAPI GatewayEndpoints, dispatch, OpenAPI specs
prismatic_supervisorCompositional SupervisionDomain supervisors, health monitoring, dependency graphs
prismatic_darkSecurity OperationsColor teams, adversarial simulation, epistemic defense

Context Mapping

Context mapping describes how bounded contexts relate to each other. DDD defines several relationship patterns:

RelationshipDescriptionPrismatic Example
Shared KernelCommon code shared between contextsprismatic_storage_core traits shared by all storage adapters
Customer-SupplierUpstream context serves downstreamprismatic_perimeter supplies data to prismatic_web
Anti-Corruption LayerTranslation layer protecting context integrityStorage adapters translating between domain and persistence models
Published LanguageDocumented interface contractOpenAPI spec for prismatic_api
Separate WaysContexts with no integrationIndependent utility apps
ConformistDownstream adopts upstream modelExternal OSINT provider response schemas
Open Host ServiceContext exposes a protocol for integrationREST API gateway for external consumers
PartnershipTwo contexts coordinate developmentprismatic_web and prismatic_api sharing authentication

Ubiquitous Language

Ubiquitous language is the shared vocabulary between developers and domain experts within a bounded context. In the Prismatic Platform, this language is enforced through multiple mechanisms:

  • Module names: PrismaticPerimeter.SecurityRating, not PrismaticPerimeter.Score
  • Function names: assess_compliance/2, not check/2
  • AIAD agent definitions: Agent specs use domain terminology (security-scanner, not agent-42)
  • Documentation: CLAUDE.md files in each app document the bounded context's ubiquitous language
  • Glossary: This glossary itself codifies the platform's ubiquitous language
  • Type names: @type security_grade :: :A | :B | :C | :D | :F, not @type grade :: integer()

The ubiquitous language is not just a naming convention -- it is a design constraint. When the language used in code diverges from the language used by domain experts, it signals a modeling problem. The Prismatic Platform maintains language alignment through code review, naming standards enforced by Credo, and the AIAD agent specification format that requires domain-specific terminology.

Tactical Patterns

Aggregates

An aggregate is a cluster of domain objects treated as a unit for transactional consistency. External references should only point to the aggregate root; internal objects are accessed through the root.

defmodule PrismaticPerimeter.Domain.AttackSurface do
  @moduledoc """
  Aggregate root for an organization's external attack surface.

  Encapsulates all discovered assets (domains, IPs, certificates, services)
  and enforces consistency rules for asset management. All modifications
  to the attack surface go through this aggregate root.
  """

  @type t :: %__MODULE__{
    organization: String.t(),
    domains: [Asset.t()],
    ip_addresses: [Asset.t()],
    certificates: [Asset.t()],
    services: [Asset.t()],
    assessed_at: DateTime.t() | nil,
    version: non_neg_integer()
  }

  defstruct [
    :organization,
    :domains,
    :ip_addresses,
    :certificates,
    :services,
    :assessed_at,
    :version
  ]

  @spec add_discovered_asset(t(), Asset.t()) :: {:ok, t()} | {:error, term()}
  def add_discovered_asset(%__MODULE__{} = surface, asset) do
    with :ok <- validate_asset(asset),
         :ok <- check_duplicate(surface, asset) do
      updated = %{surface |
        domains: maybe_add_domain(surface.domains, asset),
        ip_addresses: maybe_add_ip(surface.ip_addresses, asset),
        version: surface.version + 1
      }
      {:ok, updated}
    end
  end

  @spec remove_stale_assets(t(), DateTime.t()) :: {:ok, t()}
  def remove_stale_assets(%__MODULE__{} = surface, cutoff) do
    updated = %{surface |
      domains: Enum.reject(surface.domains, &stale?(&1, cutoff)),
      ip_addresses: Enum.reject(surface.ip_addresses, &stale?(&1, cutoff)),
      version: surface.version + 1
    }
    {:ok, updated}
  end
end

In OTP, aggregates map naturally to GenServer processes. Each aggregate instance has its own process with isolated state, enforcing the consistency boundary through the process mailbox -- only one message is processed at a time, providing serialized access without explicit locking. This is the defining insight of DDD on the BEAM: the aggregate pattern and the process model are isomorphic.

Entities and Value Objects

ConceptIdentityMutabilityPrismatic Example
EntityHas unique identity; tracked through state changesMutable (through aggregate)Domain, IPAddress, Agent
Value ObjectDefined by attributes, not identity; two VOs with same attrs are equalImmutableSecurityGrade, RiskScore, ComplianceLevel
# Entity: has identity, tracked through changes
defmodule PrismaticPerimeter.Domain.Asset do
  @moduledoc "Entity representing a discoverable attack surface asset."

  @type t :: %__MODULE__{
    id: String.t(),
    type: atom(),
    value: String.t(),
    discovered_at: DateTime.t(),
    last_seen: DateTime.t(),
    confidence: float()
  }

  defstruct [:id, :type, :value, :discovered_at, :last_seen, :confidence]
end

# Value Object: defined by attributes, immutable
defmodule PrismaticPerimeter.Domain.SecurityGrade do
  @moduledoc "Value object representing a security assessment grade."

  @enforce_keys [:grade, :score]
  defstruct [:grade, :score]

  @spec new(atom(), non_neg_integer()) :: {:ok, t()} | {:error, :invalid_grade_score_combination}
  def new(:A, score) when score >= 800, do: {:ok, %__MODULE__{grade: :A, score: score}}
  def new(:B, score) when score >= 650, do: {:ok, %__MODULE__{grade: :B, score: score}}
  def new(:C, score) when score >= 500, do: {:ok, %__MODULE__{grade: :C, score: score}}
  def new(:D, score) when score >= 350, do: {:ok, %__MODULE__{grade: :D, score: score}}
  def new(:F, score) when score >= 0, do: {:ok, %__MODULE__{grade: :F, score: score}}
  def new(_, _), do: {:error, :invalid_grade_score_combination}
end

Domain Events

Domain events represent significant occurrences within a bounded context. They are the primary mechanism for cross-context communication, enabling loose coupling between contexts.

EventSource ContextConsumer Contexts
AssetDiscoveredprismatic_perimeterprismatic_web (dashboard), search index
SecurityRatingCalculatedprismatic_perimeterprismatic_web, compliance engine
QualityViolationDetectedprismatic_safetyprismatic_claude (session tracking)
AgentStateChangedprismatic_agentsprismatic_web (agent dashboard)
SessionStartedprismatic_claudeprismatic_safety (baseline trigger)
ComplianceAssessedprismatic_perimeterprismatic_web, reporting engine

Domain events flow between contexts through the platform's PubSub system, maintaining the decoupling that DDD requires between bounded contexts.

Domain Services

Domain services encapsulate operations that do not naturally belong to any entity or value object. They represent verbs in the ubiquitous language that operate across multiple aggregates:

defmodule PrismaticPerimeter.Domain.ComplianceAssessmentService do
  @moduledoc """
  Domain service for assessing compliance of an attack surface
  against regulatory frameworks. Operates across the AttackSurface
  and ComplianceFramework aggregates.
  """

  @spec assess(AttackSurface.t(), [ComplianceFramework.t()]) ::
    {:ok, ComplianceReport.t()} | {:error, term()}
  def assess(%AttackSurface{} = surface, frameworks) do
    results = Enum.map(frameworks, fn framework ->
      {framework.name, evaluate_controls(surface, framework.controls)}
    end)

    {:ok, ComplianceReport.new(surface.organization, results)}
  end
end

Repositories

The repository pattern provides an abstraction over data persistence, allowing the domain model to remain ignorant of storage details. In the Prismatic Platform, Ecto serves as the repository implementation for relational data:

defmodule PrismaticPerimeter.Repository.AssetRepository do
  @moduledoc "Repository for persisting and retrieving attack surface assets."

  @spec find_by_organization(String.t()) :: {:ok, [Asset.t()]} | {:error, term()}
  def find_by_organization(org_id) do
    case PrismaticStorage.query(:assets, %{organization: org_id}) do
      {:ok, records} -> {:ok, Enum.map(records, &to_domain_entity/1)}
      {:error, reason} -> {:error, reason}
    end
  end

  @spec persist(AttackSurface.t()) :: {:ok, AttackSurface.t()} | {:error, term()}
  def persist(%AttackSurface{} = surface) do
    with {:ok, _} <- PrismaticStorage.upsert(:attack_surfaces, to_record(surface)) do
      {:ok, surface}
    end
  end
end

DDD and Elixir/OTP Alignment

The alignment between DDD concepts and Elixir/OTP constructs is remarkably tight:

DDD ConceptElixir/OTP ConstructWhy It Fits
Bounded ContextUmbrella appExplicit dependency boundaries, separate compilation
AggregateGenServer processSerialized state access, isolated failure domain
EntityStruct with IDPattern matching, immutable updates via %{struct | field: value}
Value ObjectStruct without IDStructural equality, immutability by default
Domain EventMessage passing / PubSubAsynchronous, location-transparent communication
RepositoryEcto RepoQuery/persistence abstraction, changesets for validation
Anti-Corruption LayerProtocol / BehaviourExplicit interface contracts, adapter pattern
Ubiquitous LanguageModule namingPrismaticPerimeter.SecurityRating reads as domain language
Domain ServiceModule with pure functionsStateless operations across aggregates
FactoryConstructor functionsnew/1, build/2 functions on domain structs

This alignment means DDD patterns in Elixir are not forced abstractions but natural expressions of the language's design philosophy. A GenServer is not "used as" an aggregate -- it inherently provides the properties an aggregate requires.

Hexagonal Architecture Integration

DDD is frequently combined with hexagonal (ports and adapters) architecture, where the domain model sits at the center and external concerns (databases, APIs, UI) connect through ports (interfaces) and adapters (implementations). The adapter pattern in the Prismatic Platform implements this directly:

+----------------------------------------------------------+
|                     Bounded Context                       |
|                                                           |
|  +---------------------------------------------------+   |
|  |              Domain Model (Core)                   |   |
|  |   Aggregates, Entities, Value Objects, Events      |   |
|  +---------------------------------------------------+   |
|         |              |              |                    |
|    [Port: Storage] [Port: Events] [Port: External]        |
|         |              |              |                    |
|  +------+------+  +---+---+  +-------+-------+           |
|  | Ecto Adapter|  | PubSub |  | OSINT Adapter |           |
|  | ETS Adapter |  | Broadway|  | API Adapter   |           |
|  +-------------+  +--------+  +---------------+           |
+----------------------------------------------------------+

The hexagonal architecture ensures that the domain model has no dependencies on infrastructure concerns. Storage adapters implement a behaviour contract defined by the domain, not the other way around. This inversion of dependencies is what allows the Prismatic Platform to swap storage backends (ETS for development, Ecto for production, Meilisearch for search) without modifying any domain code.

CQRS and Event Sourcing Synergies

DDD is frequently paired with CQRS (Command Query Responsibility Segregation) and Event Sourcing. CQRS separates the write model (commands that change state) from the read model (queries that return data), allowing each to be optimized independently. Event sourcing persists the sequence of domain events rather than current state, providing a complete audit trail and enabling temporal queries.

In the Prismatic Platform, the event-driven architecture between bounded contexts naturally supports CQRS patterns: commands are processed within a bounded context's aggregate, while read models are built by consuming domain events in downstream contexts. The prismatic_web LiveView dashboard consumes events from prismatic_perimeter to build real-time read models for the UI, without the UI needing to understand the write model's internal structure.

Common Anti-Patterns

Anti-PatternDescriptionDDD Solution
Anemic Domain ModelDomain objects with no behavior, logic in servicesRich domain model with behavior on aggregates
Big Ball of MudNo clear boundaries, everything coupledStrategic decomposition into bounded contexts
Shared DatabaseMultiple contexts sharing tablesEach context owns its data; integration through events
Leaky AbstractionInternal model exposed to consumersAnti-corruption layer, published language
God ObjectSingle entity managing everythingAggregate decomposition with clear responsibilities
Smart UIBusiness logic in presentation layerDomain model encapsulates all business rules
Cargo Cult DDDApplying all patterns regardless of complexityUse strategic DDD always; tactical DDD only for complex domains

Testing DDD Implementations

DDD implementations benefit from a layered testing strategy:

defmodule PrismaticPerimeter.Domain.AttackSurfaceTest do
  @moduledoc false
  use ExUnit.Case, async: true

  describe "add_discovered_asset/2" do
    test "adds valid domain asset to attack surface" do
      surface = build_surface()
      asset = build_asset(:domain, "example.com")

      assert {:ok, updated} = AttackSurface.add_discovered_asset(surface, asset)
      assert length(updated.domains) == length(surface.domains) + 1
      assert updated.version == surface.version + 1
    end

    test "rejects duplicate assets" do
      surface = build_surface(domains: [build_asset(:domain, "example.com")])
      duplicate = build_asset(:domain, "example.com")

      assert {:error, :duplicate_asset} = AttackSurface.add_discovered_asset(surface, duplicate)
    end
  end
end

Property-based testing is particularly valuable for DDD implementations, as it can verify aggregate invariants across thousands of random state transitions.

  • Bounded Context -- Primary decomposition unit in DDD, implemented as umbrella apps
  • CQRS -- Pattern frequently used within DDD architectures for read/write separation
  • Event Sourcing -- Persistence pattern aligned with domain events
  • Adapter Pattern -- Hexagonal architecture implementation for storage and external services
  • Message Passing -- Cross-context communication mechanism implementing domain events
  • PubSub -- Event distribution system for cross-context integration
  • Behaviour -- Callback-based contracts defining port interfaces
  • Ecto -- Repository pattern implementation for persistence
  • Agent -- AIAD agents modeled using DDD principles within their bounded contexts
  • Pattern Matching -- Elixir feature enabling expressive domain logic
  • Process Isolation -- BEAM property providing natural aggregate isolation
  • GenServer -- OTP behaviour providing aggregate process semantics

See Also

  • Architecture -- Platform domain architecture and strategic design decisions
  • Apps -- Bounded context implementations across the umbrella

Connect & Contribute

Created by Tomas Korcak (korczis) | Open Source under GHL