بنقرة واحدة
ddd
Domain-Driven Design architecture patterns and conventions for this project
القائمة
Domain-Driven Design architecture patterns and conventions for this project
Request contracts validate input AND map external API names onto domain entities
Roar::Decorator representers that turn domain entities into JSON
Presentation layer serializes domain entities to wire formats (JSON, etc.)
Repository pattern for translating between ORM rows and domain entities
How orm/ and repositories/ split responsibility in app/infrastructure/database/
When to use Dry::Struct DTO entities vs. plain Ruby class entities in domain/entities/
| name | ddd |
| description | Domain-Driven Design architecture patterns and conventions for this project |
Domain-Driven Design architecture patterns and conventions.
Look at relevant portions of the current codebase's DDD if needed, or else request a reference project if unsure the current project is a good fit.
See CLAUDE.md → "Architecture" for layer paths, file conventions, and key examples.
domain/ # Pure domain (no framework dependencies)
├── types # Shared constrained types
├── <context>/
│ ├── entities/ # Aggregate roots and entities
│ ├── values/ # Value objects
│ └── policies/ # Domain policies (business rules, actor-agnostic)
infrastructure/
├── database/
│ ├── orm/ # ORM models (thin, no business logic)
│ └── repositories/ # Maps ORM ↔ domain entities
├── <external>/ # External API adapters (Gateway + Mapper)
application/
├── services/ # Use cases, orchestration
├── policies/ # Application policies (actor-dependent authorization)
├── responses/ # Response DTOs
presentation/
└── representers/ # Serialization for API responses
Dependencies flow inward only. Domain is at the center, knows nothing about outer layers.
Allowed:
repositories/ → imports domain/entities/services/ → imports domain/, repositories/, policies/controllers/ → imports services/Forbidden:
domain/ → NEVER imports from infrastructure, application, or presentationThree distinct concepts, often conflated:
Domain logic = intrinsic computations, always true regardless of context. "These two points are 32km apart." Pure math — belongs in value objects and entities.
Domain policies = business rules a domain expert would articulate, actor-agnostic. "Attendance must be within 55m of the event location." The threshold is a business decision (not a deployment decision), but the rule itself doesn't reference who is acting. Constants for thresholds belong in the domain, not in config files or infrastructure.
Application policies = rules that depend on who is acting or application-level context. "Only teaching staff can view all attendance records." These reference roles, requestors, or use-case context.
The key constraint: The domain layer can't know about application concepts like "who is the requestor" or "what role do they have."
Heuristic: If the rule is actor-agnostic (a domain expert would state it without mentioning roles) → domain/. If it references roles, requestors, or use-case context → application/policies/.
| Concern | Layer | Why |
|---|---|---|
| Distance calculation (Haversine) | Domain (value object) | Pure math, always true |
| "Right place, right time" | Domain (policy) | Business rule, actor-agnostic |
| "Only students must comply" | Application (service orchestration) | Depends on actor role |
| "Only staff can view all records" | Application (policy) | Depends on actor role |
Group related domain rules into a single policy when they answer the same domain question (e.g., proximity + time window = "is this attendance eligible?").
Anti-pattern: policy decisions in services. Services must NOT contain business rule logic — even simple conditionals like threshold comparisons. If a domain expert would articulate the rule, it belongs in a policy, not as an if statement in a service. Services call policies; they don't replicate them.
Evolution: If a threshold might vary (per course, per campus), make it a value object rather than a constant. The threshold evolves from a constant to a repository-backed lookup without architectural refactoring.
Two building blocks that are often misclassified:
Entities have identity — they are distinct things the domain recognizes, references, and acts upon. Two entities with identical attributes but different identities are different things.
Value Objects are attributes of entities — they describe aspects of entities with no identity of their own. Two value objects with the same attributes are interchangeable.
Value objects belong to entities. Every value object should have a parent entity it describes:
| Value Object | Describes Entity |
|---|---|
TimeRange | Course, Event |
GeoLocation | Location, Attendance |
CourseRoles | Enrollment |
If a domain concept doesn't naturally belong as an attribute of any existing entity, it is likely an entity itself. A standalone concept that someone reads, references, or acts upon — that's an entity, not a value object.
Reports, invoices, statements, and similar derived/computed concepts are entities when:
An invoice regenerated from the same order has the same attributes, yet invoices are canonical DDD entities. The same reasoning applies to reports.
Identity is a domain concept, not a storage decision. A computed report that is never persisted can still be a domain entity if the domain expert treats it as a distinct thing. Persistence is an infrastructure concern.
Delivery CaveatEvans' DDD Sample models Delivery as a value object, but Delivery belongs to Cargo (its parent entity). This pattern applies when a computed concept is an attribute of an aggregate. When a computed concept has no parent entity and stands alone, the entity classification is more appropriate.
Two flavors of entity coexist in this project, distinguished by whether the entity carries behavior:
AttendanceReport.Dry::Struct with strict types, plus a to_attr_hash method. See ./entities/SKILL.md for conventions and the inline Entity::PromptLog example.Principle: Objects own their computation. The constructor receives domain objects; derived values are exposed as methods. No procedural factory that pre-computes everything and stuffs it into a passive struct.
Entity example — takes dependencies, computes on demand:
class AttendanceReport
ReportEvent = Data.define(:id, :name) # simple immutable data: use Data.define
attr_reader :course_name, :generated_at
def initialize(course:, attendances:)
@course_name = course.name
@generated_at = Time.now
@course = course
@attendances = attendances
end
def events
@events ||= raw_events.map { |e| ReportEvent.new(id: e.id, name: e.name) }
end
def student_records
@student_records ||= students.map do |enrollment|
StudentAttendanceRecord.new(enrollment:, events: raw_events, lookup: index)
end
end
private
def raw_events = @course.events_loaded? ? @course.events : []
def students = @course.enrollments_loaded? ? @course.students : []
def register = @register ||= AttendanceRegister.new(attendances: @attendances)
end
Value object example — computes from collaborators, provides value equality:
class StudentAttendanceRecord
attr_reader :email
def initialize(enrollment:, events:, lookup:)
@email = enrollment.account_email
@account_id = enrollment.account_id
@events = events
@lookup = lookup
end
def event_attendance
@event_attendance ||= @events.each_with_object({}) do |event, hash|
hash[event.id] = @lookup.attended?(@account_id, event.id) ? 1 : 0
end
end
def attend_sum = @attend_sum ||= event_attendance.values.sum
def attend_percent = @attend_percent ||= # ...compute from attend_sum and events
def ==(other)
other.is_a?(self.class) && email == other.email && event_attendance == other.event_attendance
end
alias eql? ==
def hash = [email, event_attendance].hash
end
When to use what:
| Need | Use |
|---|---|
| Object with behavior / computed methods | Plain Ruby class |
| Simple immutable data holder (2–3 fields, no logic) | Data.define |
| Value equality | Implement ==, eql?, hash |
Anti-pattern (clarified): The problem is passive struct + external factory that computes fields, not Dry::Struct itself. A Dry::Struct used purely as a DTO — values come straight from the boundary it maps to or from, nothing is calculated in flight — is fine, and is what Entity::PromptLog does. The anti-pattern is when an Entity.build(...) factory pre-computes totals, statuses, or relationships and stuffs the results into a passive struct, separating computation from the object that should own it.
When an entity holds a collection of children (e.g., a Course has Events), wrap the collection in a typed value object rather than using a raw Types::Array:
class Events
attr_reader :items
def initialize(items)
@items = items.freeze
end
def find(id) = items.find { |e| e.id == id }
def count = items.size
def to_a = items.dup
# ...domain-specific queries
end
Naming: Use plural nouns (Events, Locations, Enrollments) — consistent with the existing SystemRoles and CourseRoles convention, and natural in domain language (course.events.find(id)).
Benefits: type safety (only Entity::Event members), encapsulated query logic (move find_event, event_count off the parent entity), and the parent entity stays focused on its own concerns.
Coercion for ergonomics: Use a type constructor that auto-wraps raw arrays into the collection object. This keeps test construction simple (events: [event1, event2]) while repositories use the explicit form (Events.new(events)).
Not all "not loaded" states need a Null Object. The decision depends on how the collection flows through the system:
| Pattern | When to use | Example |
|---|---|---|
| Null Object | The attribute is passed polymorphically across layers (policies, auth, services) and callers shouldn't need to check for presence | SystemRoles / NullSystemRoles — Account.roles flows through policies and auth adapters that call .admin?, .has?() etc. |
| Optional nil | The attribute is accessed only after deliberate loading; callers choose their loading method upfront and know what they have | Course child collections — services call find_with_events or find_id and know whether children are present |
Heuristic: If the object crosses module boundaries and receivers call methods on it without knowing whether it was loaded, use a Null Object. If access is local and the caller controls loading, nil is simpler — a NoMethodError on nil clearly signals "you forgot to load."
Services are use cases. Each service is a single operation with railway-oriented flow (each step succeeds or short-circuits on failure).
Key principles:
ok, created, bad_request, forbidden, etc.) wrap results with HTTP-friendly statusTypical step flow:
Keep validation in services. Avoid premature abstraction.
Why validation belongs in services:
Controller responsibility is minimal: parse input, call service, pattern match on result.
When to extract validation:
Services often compose data from multiple repositories — an event with its location coordinates and course name, or a course with enrollment roles. These composites aren't domain entities (nobody says "enriched event"). They're application-layer concerns: the shape of what the use case returns.
Response DTOs live in application/responses/ and use Data.define.
Implementation:
# app/application/responses/event_details.rb
module Tyto
module Response
EventDetails = Data.define(
:id, :course_id, :location_id, :name, :start_at, :end_at,
:longitude, :latitude, :course_name, :location_name
)
end
end
The service builds the DTO from its repository results:
def enrich(event, location, course)
Response::EventDetails.new(
id: event.id, course_id: event.course_id, location_id: event.location_id,
name: event.name, start_at: event.start_at, end_at: event.end_at,
longitude: location&.longitude, latitude: location&.latitude,
course_name: course.name, location_name: location&.name
)
end
The representer serializes it — with a guaranteed shape, no respond_to? guards needed.
When to use response DTOs vs. passing entities directly:
| Situation | Use |
|---|---|
| Response matches a single entity's shape | Pass the entity directly to the representer |
| Response combines data from multiple entities | Response DTO (Data.define) |
| Response adds computed/derived fields not on the entity | Response DTO |
Anti-pattern: OpenStruct for composing multi-entity responses. OpenStruct has no guaranteed shape — the representer must use respond_to? guards, and typos in field names silently produce nil instead of raising errors.
Variant DTOs for different endpoints: When two endpoints return nearly the same shape but one has extra fields (e.g., user_attendance_status on a requestor-aware endpoint), use separate DTOs rather than one DTO with nil fields. This makes each endpoint's contract explicit and avoids conditional serialization logic.
External API integrations use Gateway + Mapper:
Dependency direction: Service → Mapper → Gateway. The mapper depends on the gateway, not the other way around. The gateway has no knowledge of the mapper.
Services inject the Mapper, not the Gateway. This means:
mapper.upload(video))Request → Controller parses input
↓
Service.call()
↓
step validate_input
↓
step authorize (application policy)
↓
step check_domain_rules (domain policy)
↓
step persist/fetch
↓
Success(response) or Failure(response)
↓
Controller pattern matches result
↓
Representer serializes success data
↓
Response ← JSON/etc. with status from response DTO
Seminal DDD resources for deeper exploration.
Cargo aggregate with Delivery value object and Itinerary demonstrates computed domain concepts: dddsample-core — characterization