Learnixo
Back to blog
AI Systemsintermediate

Tactical DDD Patterns — Specifications, Policies, and Domain Services

Implement tactical DDD patterns in C#: the Specification pattern for query rules, Policy objects for business rules, Domain Services for cross-aggregate logic, and Factory methods for complex construction.

Asma Hafeez KhanMay 16, 20265 min read
DDDTactical PatternsSpecificationDomain Services.NETArchitecture
Share:𝕏

Tactical Patterns Overview

The "tactical" DDD patterns are the building blocks inside a bounded context:

  Entity:           has identity — Patient, Prescription
  Value Object:     defined by values — DosageValue, PatientMrn
  Aggregate Root:   transactional boundary — Patient, Prescription
  Repository:       persistence abstraction — IPatientRepository
  Domain Event:     something that happened — PrescriptionCreatedDomainEvent
  Domain Service:   logic that doesn't fit in one aggregate
  Specification:    encapsulated, reusable query/rule predicate
  Policy:           encapsulated business rule or decision
  Factory:          complex construction logic

Specification Pattern

C#
// Specifications: reusable, composable predicates — for both querying and validation

public abstract class Specification<T>
{
    public abstract Expression<Func<T, bool>> ToExpression();

    public bool IsSatisfiedBy(T entity) => ToExpression().Compile()(entity);

    public Specification<T> And(Specification<T> other)
        => new AndSpecification<T>(this, other);

    public Specification<T> Or(Specification<T> other)
        => new OrSpecification<T>(this, other);
}

// Clinical example: patients at risk from Warfarin interaction
public sealed class HighInrRiskPatientSpecification : Specification<Patient>
{
    private readonly decimal _inrThreshold;

    public HighInrRiskPatientSpecification(decimal inrThreshold = 3.0m)
        => _inrThreshold = inrThreshold;

    public override Expression<Func<Patient, bool>> ToExpression()
        => p => p.LatestInrValue > _inrThreshold
               && p.ActivePrescriptions.Any(pr => pr.MedicationName == "Warfarin");
}

// Composing specifications
var highRiskSpec      = new HighInrRiskPatientSpecification(3.0m);
var inSpecificWardSpec = new PatientInWardSpecification(wardId);
var combinedSpec      = highRiskSpec.And(inSpecificWardSpec);

// Use in repository
var atRiskPatients = await _patientRepo.GetAsync(combinedSpec, ct);

Policy Pattern (Business Rule Objects)

C#
// Policy: encapsulates a complex business rule or decision
// Policies are domain objects — they use domain language, not application code

public interface IDosagePolicy
{
    Result<DosageValue> ValidateDose(
        string medicationName, DosageValue proposedDose, Patient patient);
}

public sealed class WarfarinDosagePolicy : IDosagePolicy
{
    private const decimal MaxWarfarinDose = 10m;  // mg
    private const decimal MinWarfarinDose = 0.5m;

    public Result<DosageValue> ValidateDose(
        string medicationName, DosageValue proposedDose, Patient patient)
    {
        if (medicationName.Equals("Warfarin", StringComparison.OrdinalIgnoreCase))
        {
            if (proposedDose.Unit != "mg")
                return Result.Failure<DosageValue>(
                    new Error("Policy.Warfarin.UnitRequired",
                        "Warfarin must be prescribed in milligrams (mg)."));

            if (proposedDose.Amount < MinWarfarinDose || proposedDose.Amount > MaxWarfarinDose)
                return Result.Failure<DosageValue>(
                    new Error("Policy.Warfarin.DoseOutOfRange",
                        $"Warfarin dose must be between {MinWarfarinDose}mg and {MaxWarfarinDose}mg."));

            if (patient.HasAllergy("Warfarin"))
                return Result.Failure<DosageValue>(
                    new Error("Policy.Warfarin.AllergyContraindication",
                        "Patient has a documented Warfarin allergy."));
        }

        return Result.Success(proposedDose);
    }
}

// Used in command handler — policies are injected, not hard-coded
public sealed class CreatePrescriptionHandler
{
    private readonly IDosagePolicy _dosagePolicy;

    public async Task<Result<Guid>> Handle(CreatePrescriptionCommand command, CancellationToken ct)
    {
        var patient = await _patientRepo.GetByIdAsync(command.PatientId, ct);
        var dose    = new DosageValue(command.DoseAmount, command.DoseUnit);

        var policyResult = _dosagePolicy.ValidateDose(command.MedicationName, dose, patient!);
        if (policyResult.IsFailure) return Result.Failure<Guid>(policyResult.Error);

        // ... continue ...
    }
}

Domain Service

C#
// Domain Service: logic that involves multiple aggregates or doesn't belong in one aggregate
// Named as a verb (action) or noun ending in "Service" in the domain language

// Example: transferring a patient between wards
// Involves two Wards and one Patient — no single aggregate is the right home

public sealed class PatientTransferService
{
    private readonly IWardRepository    _wardRepo;
    private readonly IPatientRepository _patientRepo;

    public PatientTransferService(IWardRepository wardRepo, IPatientRepository patientRepo)
    {
        _wardRepo    = wardRepo;
        _patientRepo = patientRepo;
    }

    public async Task<Result> TransferPatientAsync(
        PatientId patientId, WardId targetWardId, CancellationToken ct)
    {
        var patient    = await _patientRepo.GetByIdAsync(patientId, ct);
        var targetWard = await _wardRepo.GetByIdAsync(targetWardId, ct);

        if (patient is null)    return Result.Failure(DomainErrors.Patient.NotFound);
        if (targetWard is null) return Result.Failure(DomainErrors.Ward.NotFound);

        if (!targetWard.HasAvailableBed())
            return Result.Failure(DomainErrors.Ward.NoBedsAvailable);

        // Orchestrate across aggregates — this logic belongs in the domain service
        var sourceWardId = patient.WardId;
        patient.TransferToWard(targetWardId);
        targetWard.OccupyBed();

        if (sourceWardId.HasValue)
        {
            var sourceWard = await _wardRepo.GetByIdAsync(sourceWardId.Value, ct);
            sourceWard?.FreeBed();
        }

        return Result.Success();
    }
}

Factory Pattern for Complex Construction

C#
// Factory: complex construction that shouldn't live in the aggregate constructor
// Especially useful when creation requires loading data from the database

public sealed class PrescriptionFactory
{
    private readonly IPatientRepository    _patientRepo;
    private readonly IDrugInteractionCheck _drugCheck;

    public async Task<Result<Prescription>> CreateAsync(
        PatientId patientId, string medicationName,
        DosageValue dose, ClinicianId prescriberId, CancellationToken ct)
    {
        // 1. Load what we need (aggregate doesn't do this itself)
        var patient = await _patientRepo.GetByIdAsync(patientId, ct);
        if (patient is null) return Result.Failure<Prescription>(DomainErrors.Patient.NotFound);

        // 2. Check drug interactions (requires external knowledge)
        var activeMeds = patient.ActivePrescriptions.Select(p => p.MedicationName);
        var interactionResult = await _drugCheck.CheckAsync(medicationName, activeMeds, ct);
        if (interactionResult.HasInteraction)
            return Result.Failure<Prescription>(new Error(
                "Prescription.DrugInteraction",
                $"{medicationName} interacts with {interactionResult.ConflictingDrug}."));

        // 3. Create the aggregate (aggregate itself validates domain invariants)
        return Prescription.Create(patientId, medicationName, dose, prescriberId);
    }
}

Production issue I've seen: A team put Warfarin dosage validation logic directly in the HTTP request validator (FluentValidation). The rule: Warfarin dose must be between 0.5mg and 10mg, and the patient must not have a documented Warfarin allergy. When the same prescription creation logic was used in a background batch import (no HTTP request, no FluentValidation), the allergy check was completely bypassed. 12 imported prescriptions violated the allergy rule. Moving the rule to a WarfarinDosagePolicy domain object made it enforceable from both the HTTP endpoint and the batch import.


Key Takeaway

Domain Services handle logic that spans multiple aggregates or doesn't fit in one. Specifications encapsulate reusable query predicates and are composable with AND/OR. Policies encapsulate complex business rules — they are domain objects, not application code. Factories handle complex construction that requires loading data or coordination. These tactical patterns keep business rules in the domain layer, not scattered across handlers and validators.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.