Skip to main content
Software Alchemy

The Art and Science of Software Development

Scaffold Your Clean DDD Web Application - Part 6: Domain-Driven Design Workflow Patterns

Rocketbook (Affiliate)

Clean DDD Workflow

Image by Mudassar Iqbal from Pixabay

Introduction

Previously, I discussed our intention to maintain a separation between Presentation (View Model), Domain, and Persistence entities in our architecture. I talked about some of the difficulties this design decision presents, and how we can mitigate those obstacles using an elegant, methodical entity-mapping strategy. In this blog entry, I build off of that discussion and elaborate on some of the high-level architectural patterns which emerge as a result. This is especially relevant if you are using CQRS, although that is not a prerequisite, and these patterns will apply just as readily to other approaches, including traditional N-layered architectures. Let’s dig in!

General Philosophy On Software And Life

What I’m about to present are high-level architectural design patterns that seem to emerge when implementing CQRS workflows in a Clean Domain-Driven Design solution. I present these as suggestion and inspiration for your solutions, and not as a dogmatic, prescriptive approach which must be followed to the letter. As is often the case in software development, this is more of an art than a science, and the appropriateness of any of these patterns must be evaluated in the context of the particular problem you are trying to solve. YOU are the architect.

I remember (back in the day) when I was going through my Design Patterns Phase, as we all did when we were mid-level. I had this perfectionistic approach in which I sought to find the ideal pattern to apply to every different situation, as if there is some Rosetta stone for software development that indicates THE design pattern for every class of problem you run across (spoiler: there isn’t one). Then I stumbled across a blog posting that asserted that the best book on programming, bar none, is Tao of Jeet Kune Do, a book on martial arts by the legendary martial artist, Bruce Lee. I promptly bought the book, read it cover to cover, and then realized that the blogger was basically correct. (FYI—I would link to the original blog entry I read but it was over ten years ago and I’m having trouble finding it.) My entire worldview and general philosophy on software development were irrevocably altered from that point on.

"Use only that which works, and take it from any place you can find it."

― Bruce Lee, Tao of Jeet Kune Do

Getting back to the discussion at hand, remember that at the end of the day our goal is to build and ship a product that solves a hard problem, either for somebody else or for ourselves. The essential principle is to do what is necessary to get the job done while staying mindful of core patterns and practices that are conducive to scalability and maintainability.

As stated before, I noticed these patterns while using CQRS with Clean Domain-Driven Design. However, you may find that they work just as well when building a DDD system using some other approach, like the Repository pattern or a service-based methodology. Heck, these will probably work just fine in a traditional N-layered (ex: 3-tiered) application.

Alongside the patterns themselves, I present estimates of the frequency of which you may find yourself applying each of them in a typical enterprise business application. I want to emphasize that these are estimates, and your mileage may vary regarding your own use case.

As always, I will try to present code samples where appropriate, and refer back to the demo application if it helps clarify certain concepts.

Basic Guidelines

If you read my previous blog entry about Persistence you’ll recall that we are using both Entity Framework Core and Dapper together to read from/write to an underlying database. Like a martial artist’s right arm and left leg, each is very different and suited to different purposes; however, when working together, they are complementary and synergize to yield highly effective results. Furthermore, there is an inherent asymmetry in the needs of Command (Write) operations vs. Query (Read) operations, which will also influence the appropriateness of any given pattern. With that in mind, here are some general guidelines for use.

1. Bypassing the Persistence and/or Domain Layers (Fast)

Some patterns will want to cut past the Persistence and/or Domain layers entirely. This is common in most queries that simply hydrate View Models directly. Dapper is a no-brainer for this. If there were some mandate that I only use either Dapper or EF Core to build an entire software solution, I’d probably choose Dapper—and make no mistake, I’ve been a HUGE fan of Entity Framework for years, even going back to the .NET Framework days. Why is this? Quite simply, because Dapper is simple to use and it is a beast, performance-wise. Here are some common scenarios in which we’ll generally want to lean on it.

  • Frequently-called queries (need for speed). Dapper is fast. Period.
  • Need to project data model into more complex shapes, like composite View Models. In this regard, Dapper also provides a degree of flexibility.
  • Simplistic updates, like hard (or soft) deletes. This has traditionally been an Achilles’ heel for Entity Framework, and trying to go the EF route would hurt performance more than it’d help in any other regard.

2. Going Through the Persistence Layer (Fluent)

In other situations, especially those going down the stack, it makes sense to go through the Persistence layer. In general, these will lean on Entity Framework Core, although we can use Dapper to hydrate Persistence (EF) entities directly. Here are some such scenarios:

  • Need to set up and orchestrate complex entity configurations. In this case, the fluency of the EF Core syntax works in our favor, along with the compiler checking we get from using it. One of the big downsides to using Dapper, or any other micro-ORM tool like it, is that if we make drastic changes to the underlying database schema, then we need to go back and modify SQL queries in our code, which is tedious and error-prone.
  • The workflow would benefit from the readability and elegance of LINQ. Again, by leveraging the simplicity and correctness of a declarative programming style, we can manage complexity inside workflows with a lot of moving pieces, and the fact that EF is built on classes rather than SQL code gives us a compile-time safety net when refactoring the data model.

Another non-trivial benefit to using EF is that it greatly simplifies architecting the database and running migrations against it as it evolves. Being able to use our native language (C#) to build out the data model is a massive productivity boost.

3. Asymmetry

As stated earlier, Queries will slant toward CRUD reads and bypass the domain layer more often than not (with a few notable exceptions). Commands will slant toward using the Domain layer for validation and orchestration of business logic (with notable exceptions as well). This is simply the nature of the beast for most software applications. The Pareto Principle (80/20 Rule) often applies to each.

4. Questions to Ask Oneself

Here are a few questions you might want to keep in mind when determining the appropriateness of any of the following patterns:

  • Is this a high-level Read (Query) or Write (Command) operation?
  • Does it require complex validation, business rule enforcement, or data aggregation?
  • Is it dependent upon persisted state in the underlying data store?
  • Will it be called frequently? Is there a need for performance?

The Patterns

Here I present the patterns, categorized into four different categories: A, B, C, and D. The categories are presented in order of hypothetical preponderance—with the most common appearing first. Where applicable, I also list workflow examples from the demo application and code samples, if possible.

Category A—CRUD Read Patterns


A1: CRUD Read Directly From Data Store

Clean DDD Pattern A1

Description: Fast CRUD read having no dependency on Domain concerns, and with a heavy bias toward Presentation (View Model) or more uncommonly, Identity Model. As stated earlier, most Queries will resemble this.

Preponderance: Approximately 80% of Queries.

Persistence Frameworks Used: Dapper.

Workflow Examples:

  • GetCustomer
  • GetCustomerId
  • GetTenantsForCustomer
  • GetEmployee
  • GetEmployeeList
  • GetEmployeeOrgChart
  • GetPaidTimeOffPolicyDetail
  • GetPaidTimeOffPolicyList
  • GetAuthorizedTenantsForUser
  • GetTenant
  • GetTenantEmployeesForUser
  • GetTenantIdFromAssignmentKey
  • GetTenantIdFromSlug
  • GetUserHasTenantAccess
  • GetUserStatus

Code Sample:


public class GetUserStatusQueryHandler : IRequestHandler<GetUserStatusQuery, UserStatusViewModel>
{
    private readonly IApplicationReadDbFacade facade;
    public GetUserStatusQueryHandler(IApplicationReadDbFacade facade) => this.facade = facade ?? throw new ArgumentNullException(nameof(facade));
    public async Task<UserStatusViewModel> Handle(GetUserStatusQuery request, CancellationToken cancellationToken)
    {
        var userStatus = await facade.QueryFirstOrDefaultAsync<UserStatusViewModel>(@"
				SELECT TOP 1
                    u.IsCustomer,
                    c.Id CustomerId,
	                CASE WHEN c.Id IS NULL THEN 0 ELSE 1 END HasProvidedCustomerInformation,
	                CASE WHEN e.Id IS NULL THEN 0 ELSE 1 END HasProvidedEmployeeInformation
                FROM AspNetUsers u WITH(NOLOCK)
                LEFT JOIN Customers c WITH(NOLOCK) ON c.AspNetUsersId = u.Id
                LEFT JOIN Employees e WITH(NOLOCK) ON e.AspNetUsersId = u.Id AND (@TenantId IS NULL OR e.TenantId = @TenantId)
                WHERE u.Id = @AspNetUsersId
            ", request, cancellationToken: cancellationToken);
        userStatus.AuthorizedTenants = (await facade.QueryAsync<int>("SELECT TenantId FROM TenantAspNetUsers t WITH(NOLOCK) WHERE AspNetUsersId = @AspNetUsersId", request, cancellationToken: cancellationToken)).ToArray();
        return userStatus;
    }
}

A2: CRUD Read Via Persistence Entities

Clean DDD Pattern A2

Description: Infrequent read operations which may or may not benefit from the fluency of LINQ that Entity Framework provides, or Persistence entities may be hydrated directly using Dapper. Little to no need to project to complex, composite View Models, etc.

Preponderance: Less than 10-20% of Queries.

Persistence Frameworks Used: Entity Framework Core and/or Dapper.

Workflow Examples:

  • GetPaidTimeOffRequestsForEmployee

Code Sample:


public class GetPaidTimeOffRequestsForEmployeeQueryHandler : IRequestHandler<GetPaidTimeOffRequestsForEmployeeQuery, PaidTimeOffRequestViewModel[]>
{
    private readonly IApplicationWriteDbContext context;

    private readonly IDbEntityToViewModelMapper<PaidTimeOffRequestEntity, PaidTimeOffRequestViewModel> mapper;

    public GetPaidTimeOffRequestsForEmployeeQueryHandler(IApplicationWriteDbContext context, IDbEntityToViewModelMapper<PaidTimeOffRequestEntity, PaidTimeOffRequestViewModel> mapper)
    {
        this.context = context ?? throw new ArgumentNullException(nameof(context));
        this.mapper = mapper ?? throw new ArgumentNullException(nameof(mapper));
    }

    public async Task<PaidTimeOffRequestViewModel[]> Handle(GetPaidTimeOffRequestsForEmployeeQuery request, CancellationToken cancellationToken)
    {
        // PERSISTENCE LAYER
        var employee = request.EmployeeId != null ?
            await context.Employees.Include(e => e.ForPaidTimeOffRequests).FirstOrDefaultAsync(e => e.Id == (int)request.EmployeeId && e.TenantId == request.TenantId) :
            context.Employees.Include(e => e.ForPaidTimeOffRequests).FirstOrDefault(e => e.AspNetUsersId == request.AspNetUsersId) ??
            throw new NotFoundException("Invalid employee ID or ASP.NET user ID specified.");

        // PRESENTATION LAYER
        var vms = (from req in employee.ForPaidTimeOffRequests.OrderBy(r => r.StartDate) select mapper.Map(req)).ToArray();
        return vms;
    }
}

Category B—Domain-Mediated Write Patterns


B1: Domain-Mediated Write With Entity Hydration From Data Store

Clean DDD Pattern B1

Description: Perhaps the most common of Domain-centric write patterns. Persistence entities are hydrated (read) from the underlying data store to facilitate orchestration of a complex write operation. Business logic-heavy operations will tend to resemble this, as it is necessary to retrieve persisted state to perform necessary computations. For example, it would be necessary to retrieve paid time off requests for an employee to calculate whether a pending request is valid, would overlap with another pending request, etc. The complexity of these types of operation will tend to lean toward the use of Entity Framework Core, with Dapper as a handy fallback.

Preponderance: Approximately 50-80% of Commands.

Persistence Frameworks Used: Entity Framework Core and/or Dapper.

Workflow Examples:

  • AddOrUpdateEmployee
  • SubmitNewPaidTimeOffRequest

Code Samples (GitHub):


B2: Domain-Mediated Write Without Entity Hydration From Data Store

Clean DDD Pattern B2

Description: Validation or business logic-heavy operations are performed that have no particular dependency on persisted state. Persistence entities are used to help in the orchestration. I would regard this as a rare pattern in the wild.

Preponderance: Less than 10% of Commands.

Persistence Frameworks Used: Entity Framework Core and/or Dapper.


B3: Domain-Mediated Write Without Entity Hydration, Directly To Data Store

Clean DDD Pattern B3

Description: Much like Pattern B2, validation or business logic-heavy operations are performed that have no particular dependency on persisted state. Rather than use Persistence entities, state is persisted directly to the database, thus achieving a performance benefit and possibly simpler code.

Preponderance: Less than 10% of Commands.

Persistence Frameworks Used: Dapper.

Category C—CRUD Write Patterns


C1: CRUD Write

Clean DDD Pattern C1

Description: Just because we’re building enterprise business applications does not mean that EVERY write operation is subject to business rule validation or otherwise has to pass through the Domain layer. In fact, there are many situations in which it doesn’t make any sense to do so. Remember what I said earlier about Bruce Lee and Jeet Kune Do?

"Having totality means being capable of following "what is," because "what is" is constantly moving and constantly changing. If one is anchored to a particular view, one will not be able to follow the swift movement of "what is."

― Bruce Lee, Tao of Jeet Kune Do

Various subsystems or areas of the application will tend toward CRUD data input, add/update semantics, etc. and that’s perfectly OK. When it seems logical to do so, we may employ a pattern like this, either with or without Entity Framework Core, to perform database updates.

Preponderance: Approximately 20-50% of Commands.

Persistence Frameworks Used: Entity Framework Core and/or Dapper.

Workflow Examples:

  • AddOrUpdateCustomer
  • AddOrUpdateTenant
  • ProvisionTenant
  • SeedInitialData

Code Sample:


public class AddOrUpdateCustomerCommandHandler : IRequestHandler<AddOrUpdateCustomerCommand, CustomerViewModel>
{
    private readonly IApplicationWriteDbContext context;
    private readonly IViewModelToDbEntityMapper<CustomerViewModel, CustomerEntity> customerMapper;
    public AddOrUpdateCustomerCommandHandler(
        IApplicationWriteDbContext context,
        IViewModelToDbEntityMapper<CustomerViewModel, CustomerEntity> customerMapper
        )
    {
        this.context = context ?? throw new ArgumentNullException(nameof(context));
        this.customerMapper = customerMapper ?? throw new ArgumentNullException(nameof(customerMapper));
    }

    public async Task<CustomerViewModel> Handle(AddOrUpdateCustomerCommand request, CancellationToken cancellationToken)
    {
        // PRESENTATION/APPLICATION LAYER
        var customerViewModel = request.Customer;

        // PERSISTENCE LAYER
        var customerAdded = false;
        using (var transaction = await context.Database.BeginTransactionAsync(cancellationToken))
        {
            var sqlTransaction = transaction.GetDbTransaction();
            var customerEntity = await context.Customers.AsNoTracking().FirstOrDefaultAsync(c => c.AspNetUsersId == request.AspNetUsersId, cancellationToken: cancellationToken);
            if (customerEntity != null)
            {
                // Update.
                customerMapper.Map(customerViewModel, customerEntity);
            }
            else
            {
                // Add.
                customerEntity = customerMapper.Map(customerViewModel);
                customerAdded = true;
            }
            customerEntity.AspNetUsersId = request.AspNetUsersId;
            await context.Customers.AddAsync(customerEntity, cancellationToken);
            context.Entry(customerEntity).State = customerAdded ? EntityState.Added : EntityState.Modified;

            await context.SaveChangesAsync(cancellationToken);
            await transaction.CommitAsync(cancellationToken);
        }
        return customerViewModel;
    }
}

C2: CRUD Write Directly To Data Store

Clean DDD Pattern C2

Description: Like in the previous pattern, we are doing CRUD write operations directly to the database. What’s different in this pattern is that both the Domain and Persistence layers are bypassed. This is useful for fast or sweeping changes to the data model, like simple updates or hard deletion. Since Persistence entities aren’t used, this precludes using Entity Framework Core, and we use Dapper instead to do the job.

Preponderance: Approximately 20-50% of Commands.

Persistence Frameworks Used: Dapper.

Workflow Examples:

  • ClearTables
  • DeleteTenant

Code Sample:


public class DeleteTenantCommand : IRequest<DeleteTenantViewModel>
{
    public DeleteTenantViewModel DeleteTenant { get; set; } = default!;

    public class DeleteTenantCommandHandler : IRequestHandler<DeleteTenantCommand, DeleteTenantViewModel>
    {
        private readonly IApplicationWriteDbContext context;

        private readonly IApplicationWriteDbFacade facade;

        public DeleteTenantCommandHandler(IApplicationWriteDbFacade facade, IApplicationWriteDbContext context)
        {
            this.facade = facade ?? throw new ArgumentNullException(nameof(facade));
            this.context = context ?? throw new ArgumentNullException(nameof(context));
        }

        public async Task<DeleteTenantViewModel> Handle(DeleteTenantCommand request, CancellationToken cancellationToken)
        {
            var tenantViewModel = request.DeleteTenant;
            var tenantName = await facade.QueryFirstOrDefaultAsync<string>(@"SELECT TOP 1 Name From Tenants WITH(NOLOCK) WHERE Id = @TenantId", tenantViewModel);
            if (tenantName.ToUpper() != tenantViewModel.ConfirmationCode.ToUpper())
            {
                throw new ApplicationLayerException("Invalid confirmation provided for delete tenant command.");
            }

            using (var transaction = await context.Database.BeginTransactionAsync())
            {
                var sqlTransaction = transaction.GetDbTransaction();
                await facade.ExecuteAsync(
                    string.Join("\r\n", from t in facade.GetTenantTables() select $"DELETE FROM {t} WHERE TenantId = @TenantId")
                    + "\r\nDELETE FROM Tenants WHERE Id = @TenantId", tenantViewModel, sqlTransaction);
                await transaction.CommitAsync();
            }
            return tenantViewModel;
        }
    }
}

Category D—Domain-Mediated Read Patterns


D1: Domain-Mediated Read Via Persistence Entities

Clean DDD Pattern D1

Description: While not as common as CRUD read operations, there may be situations in which it makes sense to perform complex orchestrations which depend upon Domain logic. A good example would be reporting. Entity Framework Core is great under these conditions since the fluency of LINQ can be useful for representing data projections, transformations, and the like.

Preponderance: At most 20% of Queries.

Persistence Frameworks Used: Entity Framework Core and/or Dapper.

Workflow Examples:

  • GetPaidTimeOffRequestsForTenant
  • VerifyOrganization

Code Sample:


public class GetPaidTimeOffRequestsForTenantQuery : IRequest<PaidTimeOffRequestViewModel[]>
{
    public int TenantId { get; set; }
    public class GetPaidTimeOffRequestsForTenantQueryHandler : IRequestHandler<GetPaidTimeOffRequestsForTenantQuery, PaidTimeOffRequestViewModel[]>
    {
        private readonly IApplicationWriteDbContext context;
        private readonly IDbEntityToDomainEntityMapper<PaidTimeOffRequestEntity, PaidTimeOffRequest> dbEntityMapper;
        private readonly IDomainEntityToViewModelMapper<PaidTimeOffRequest, PaidTimeOffRequestViewModel> domainEntityMapper;
        private readonly PaidTimeOffRequestService paidTimeOffRequestService;

        public GetPaidTimeOffRequestsForTenantQueryHandler(
            IApplicationWriteDbContext context,
            PaidTimeOffRequestService paidTimeOffRequestService,
            IDbEntityToDomainEntityMapper<PaidTimeOffRequestEntity, PaidTimeOffRequest> dbEntityMapper,
            IDomainEntityToViewModelMapper<PaidTimeOffRequest, PaidTimeOffRequestViewModel> domainEntityMapper
            )
        {
            this.context = context ?? throw new ArgumentNullException(nameof(context));
            this.paidTimeOffRequestService = paidTimeOffRequestService ?? throw new ArgumentNullException(nameof(paidTimeOffRequestService));
            this.dbEntityMapper = dbEntityMapper ?? throw new ArgumentNullException(nameof(dbEntityMapper));
            this.domainEntityMapper = domainEntityMapper ?? throw new ArgumentNullException(nameof(domainEntityMapper));
        }

        public Task<PaidTimeOffRequestViewModel[]> Handle(GetPaidTimeOffRequestsForTenantQuery request, CancellationToken cancellationToken)
        {
            // PERSISTENCE
            var tenantRequestEntities = (from r in context.PaidTimeOffRequests where r.TenantId == request.TenantId select r).ToList();

            // DOMAIN
            var paidTimeOffRequests = (from r in tenantRequestEntities select dbEntityMapper.Map(r)).ToList();
            paidTimeOffRequests = paidTimeOffRequestService.GetPaidTimeOffRequestsWithStatusUpdates(paidTimeOffRequests, DateTime.Today);

            return Task.FromResult((from r in paidTimeOffRequests select domainEntityMapper.Map(r)).ToArray());
        }
    }
}

D2: Domain-Mediated Read Directly From Data Store

Clean DDD Pattern D2

Description: Dapper is used to directly hydrate Domain entities, which are then immediately used for business logic or validation. This pattern is useful for fast validation logic, such as reacting to user input when filling in form information, typeahead logic, etc. In the demo application, I’ve created a Reactive forms validator that examines three different controls—start date, end date, and hours requested—for when an employee is requesting time off.

Preponderance: At most 20% of Queries.

Persistence Frameworks Used: Dapper.

Workflow Examples:

  • ValidateRequestedPaidTimeOffHours

Code Samples:

On the API side...


public class ValidateRequestedPaidTimeOffHoursQueryHandler : IRequestHandler<ValidateRequestedPaidTimeOffHoursQuery, PaidTimeOffRequestValidationResult>
{
    private readonly IApplicationReadDbFacade facade;
    private readonly IViewModelToDomainEntityMapper<ValidateRequestedPaidTimeOffHoursViewModel, PaidTimeOffRequest> mapper;
    private readonly PaidTimeOffRequestService paidTimeOffRequestService;

    public ValidateRequestedPaidTimeOffHoursQueryHandler(
        IApplicationReadDbFacade facade,
        PaidTimeOffRequestService paidTimeOffRequestService,
        IViewModelToDomainEntityMapper<ValidateRequestedPaidTimeOffHoursViewModel, PaidTimeOffRequest> mapper)
    {
        this.facade = facade ?? throw new ArgumentNullException(nameof(facade));
        this.paidTimeOffRequestService = paidTimeOffRequestService ?? throw new ArgumentNullException(nameof(paidTimeOffRequestService));
        this.mapper = mapper ?? throw new ArgumentNullException(nameof(mapper));
    }

    public async Task<PaidTimeOffRequestValidationResult> Handle(ValidateRequestedPaidTimeOffHoursQuery request, CancellationToken cancellationToken)
    {
        var viewModel = request.ValidationRequest;
        var tentativeRequest = mapper.Map(viewModel);
        var today = DateTime.Today;
        var parms = new { request.AspNetUsersId, viewModel.EndDate, viewModel.StartDate, viewModel.ForEmployeeId, viewModel.HoursRequested, viewModel.TenantId };

        // TODO: add domain validation/authorization if submitting on behalf of another employee (i.e. manager is submitting).
        var paidTimeOffPolicy = await facade.QueryFirstOrDefaultAsync<PaidTimeOffPolicy>(@"
            SELECT TOP 1 p.[Id]
                    ,p.[AllowsUnlimitedPto]
                    ,p.[EmployeeLevel]
                    ,p.[IsDefaultForEmployeeLevel]
                    ,p.[MaxPtoHours]
                    ,p.[Name]
                    ,p.[PtoAccrualRate]
                FROM PaidTimeOffPolicies p WITH(NOLOCK)
                JOIN Employees e WITH(NOLOCK) ON e.PaidTimeOffPolicyId = p.Id AND e.TenantId = p.TenantId
                WHERE ((@ForEmployeeId IS NULL AND e.AspNetUsersId = @AspNetUsersId) OR e.Id = @ForEmployeeId) AND e.TenantId = @TenantId
        ", parms, cancellationToken: cancellationToken);
        if (paidTimeOffPolicy == null)
        {
            throw new NotFoundException("PTO policy not found or invalid.");
        }
        var existingRequests = await facade.QueryAsync<PaidTimeOffRequest>(@"
            SELECT p.[Id]
                    ,p.[ApprovalStatus]
                    ,p.[EndDate]
                    ,p.[ForEmployeeId]
                    ,p.[HoursRequested]
                    ,p.[StartDate]
                    ,p.[Paid]
                    ,p.[SubmittedById]
                FROM [PaidTimeOffRequests] p WITH(NOLOCK)
                JOIN Employees e WITH(NOLOCK) ON e.Id = p.ForEmployeeId AND e.TenantId = p.TenantId
                WHERE ((@ForEmployeeId IS NULL AND e.AspNetUsersId = @AspNetUsersId) OR e.Id = @ForEmployeeId) AND e.TenantId = @TenantId
        ", parms, cancellationToken: cancellationToken);

        return paidTimeOffRequestService.ValidatePaidTimeOffRequest(tentativeRequest, existingRequests, paidTimeOffPolicy, today);
    }
}

On the UI side...


form = this.fb.group(
    {
        startDate: [undefined, [Validators.required]],
        endDate: [undefined, [Validators.required]],
        forEmployeeId: [undefined, []],
        hoursRequested: [0, []],
        result: [PaidTimeOffRequestValidationResult.OK, []],
        tenantId: [0, []]
    },
    { asyncValidator: requestedPaidTimeOffHoursValidator(this.timeOffClient, this.ngUnsubscribe) }
);
...
export function getErrorMessageFromPtoRequestValidationResult(result: PaidTimeOffRequestValidationResult): string {
    let msg = "";
    switch (result) {
        case PaidTimeOffRequestValidationResult.NotEnoughHours:
            msg = "Not enough time accrued.";
            break;
        case PaidTimeOffRequestValidationResult.OverlapsWithExisting:
            msg = "Overlaps with an existing PTO request.";
            break;
        case PaidTimeOffRequestValidationResult.InThePast:
            msg = "Cannot be in the past.";
            break;
        case PaidTimeOffRequestValidationResult.TooFarInTheFuture:
            msg = "Too far into the future.";
            break;
        case PaidTimeOffRequestValidationResult.StartDateAfterEndDate:
            msg = "Start date must be before end date.";
            break;
        case PaidTimeOffRequestValidationResult.OK:
            return null;
    }
    return msg;
}

export function requestedPaidTimeOffHoursValidator(timeOffClient: TimeOffClient, ngUnsubscribe: Subject<unknown>): AsyncValidatorFn {
    return (control: AbstractControl): Promise<{ [key: string]: any } | null> | Observable<{ [key: string]: any } | null> => {
        if (isEmptyInputValue(control.value)) {
            return of(null);
        } else {
            // control is a reference to the whole form.
            return control.valueChanges.pipe(
                debounceTime(500),
                take(1),
                switchMap((_) => {
                    const viewModel = new ValidateRequestedPaidTimeOffHoursViewModel();
                    viewModel.init(control.value);
                    if (viewModel.hoursRequested && viewModel.startDate && viewModel.endDate) {
                        return timeOffClient.validateRequestedPaidTimeOffHours(viewModel).pipe(
                            map((result: PaidTimeOffRequestValidationResult) => {
                                const msg = getErrorMessageFromPtoRequestValidationResult(result);
                                return !!msg ? { invalid: { message: msg } } : null;
                            }),
                            takeUntil(ngUnsubscribe)
                        );
                    }
                    return of(null);
                })
            );
        }
    };
}

D3: Domain-Mediated Read Without Any Data Store Interaction

Clean DDD Pattern D3

Description: Sometimes it makes sense to have the UI call API routines which are not dependent upon any particular state, and therefore should not interact with the database. This could be a good approach to keep complex business logic out of the UI (where it doesn’t belong anyway), thereby reducing the size of the UI bundle delivered to the user’s browser. Additionally, using server-side technologies like C#, F#, LINQ, etc. allows for a more elegant solution. Or, perhaps that logic is sensitive and it makes sense to keep it on the server. Remember that anything happening inside the user’s browser should not be trusted.

Preponderance: Likely less than 5-10% of Queries.

Persistence Frameworks Used: None.

Workflow Examples:

  • GetNewAssignmentKey

Code Sample (this is admittedly a trivial example):


public class GetNewAssignmentKeyQueryHandler : IRequestHandler<GetNewAssignmentKeyQuery, Guid>
{
    public Task<Guid> Handle(GetNewAssignmentKeyQuery request, CancellationToken cancellationToken) => Task.FromResult(Guid.NewGuid());
}

Conclusion

I started by waxing philosophical, mentioning the book The Tao of Jeet Kune Do by Bruce Lee, and how its concepts are surprisingly relatable to software development. Specifically, I talked about how the code-level and architectural design patterns which we employ in the art of software development should flow naturally, non-dogmatically, and how we should be open to employing different approaches as the problem at hand warrants. I discussed a series of architectural patterns that seem to emerge when doing CQRS with Clean DDD, though neither of those is mandatory to use the patterns. I brought up basic guidelines for the application of those patterns, and considerations for deciding when one may or may not be appropriate for a given situation. Finally, I presented the patterns themselves, offering up suggestions for scenarios in which they could be useful.

A Final Word From The Legend

"Set patterns, incapable of adaptability, of pliability, only offer a better cage. Truth is outside of all patterns."

― Bruce Lee, Tao of Jeet Kune Do

Experts / Authorities / Resources


Bruce Lee

Wikipedia

Persistence Tools


This is entry #6 in the Scaffold Your Clean DDD Web App Series

If you want to view or submit comments you must accept the cookie consent.