Home

Awesome

VShop - Modular Monolith with DDD

About

VShop is a sample .NET application built as Modular Monolith with Domain-Driven Design (DDD) approach. Each module is an independent vertical slice with its custom architecture. The overall integration between modules is mostly based on the event-driven approach to achieve greater autonomy between individual modules. Essentially, the application demonstrates a mix of architecture styles and patterns:

Inter-module communication

The VShop application uses two communication types:

Process Manager (PM)

Event Sourcing is used to build a process manager instance's state and as a log of all incoming and outgoing messages:

Process manager understands the concept of time so it can schedule events used as:

internal class OrderingProcessManager : ProcessManager
    {
        public EntityId ShoppingCartId { get; private set; }
        public EntityId OrderId { get; private set; }
        public OrderingProcessManagerStatus Status { get; private set; }
        
        public OrderingProcessManager()
        {
            RegisterEvent<ShoppingCartCheckoutRequestedDomainEvent>(Handle);
            RegisterEvent<OrderPlacedDomainEvent>(Handle);
            RegisterEvent<PaymentGracePeriodExpiredDomainEvent>(Handle);
            RegisterEvent<PaymentSucceededIntegrationEvent>(Handle);
            RegisterEvent<OrderStatusSetToPaidDomainEvent>(Handle);
            RegisterEvent<OrderStockProcessingGracePeriodExpiredDomainEvent>(Handle);
            RegisterEvent<OrderStockProcessedIntegrationEvent>(Handle);
            RegisterEvent<OrderStatusSetToPendingShippingDomainEvent>(Handle);
            RegisterEvent<ShippingGracePeriodExpiredDomainEvent>(Handle);
        }

        private void Handle(ShoppingCartCheckoutRequestedDomainEvent @event, Instant _) 
            => RaiseCommand(new PlaceOrderCommand(OrderId, ShoppingCartId));

        private void Handle(OrderPlacedDomainEvent @event, Instant now)
        {
            RaiseCommand(new DeleteShoppingCartCommand(ShoppingCartId));
            
            // Schedule a reminder for payment.
            ScheduleReminder
            (
                new PaymentGracePeriodExpiredDomainEvent(OrderId),
                now.Plus(Duration.FromMinutes(Settings.PaymentGracePeriodInMinutes))
            );
        }
        
        // Removed for brevity.
}

Testing

Both unit and integration tests have been implemented for Sales and ProcessManager modules. The classical (Detroit) school principles were followed as they decrease tight coupling with implementation details.

Due to asynchronous communication, some tests must wait for the result at certain times. To correctly implement such tests, the Sampling technique and implementation described in the Growing Object-Oriented Software, Guided by Tests book was used.

Tests preview:

Atomicity and resiliency

The several approaches are used when dealing with the data consistency problem:

Idempotence

All command and event handlers are idempotent - processing the same event or command wouldn't do any harm. In most of the cases, the system simply queries the message store (EventStoreDB) and determines if the command/event being received has already been processed. If so, the system would redispatch the outgoing events and commands, thereby allowing the continuation of the process in case of retry.

Aggregate store:

    public async Task<TAggregate> LoadAsync(EntityId aggregateId, CancellationToken cancellationToken = default)
    {
        IReadOnlyList<MessageEnvelope<IBaseEvent>> messageEnvelopes = await _eventStoreClient
            .ReadStreamForwardAsync<IBaseEvent>(GetStreamName(aggregateId), cancellationToken);

        if (messageEnvelopes.Count is 0) return default;

        TAggregate aggregate = new();
        aggregate.Load(messageEnvelopes.ToMessages());
        
        IList<MessageEnvelope<IBaseEvent>> processed = messageEnvelopes
            .Where(e => e.MessageContext.Context.RequestId == _context.RequestId).ToList();

        if (!processed.Any()) return aggregate;
        
        foreach ((IBaseEvent @event, IMessageContext messageContext) in processed)
            _messageContextRegistry.Set(@event, messageContext);
        
        await PublishAsync(processed.ToMessages(), cancellationToken);   // Re-publish messages.
        aggregate.Restore();   // Change aggregate's state.

        return aggregate;
    }

Message Format

Message schema is declared using Protocol Buffers and used to generate (de)serialization code when saving messages to databases (EventStoreDB and Postgres). Since Protocol Buffer schema is index-based there is no impact when changing a field name (which is a major plus).

Protocol Buffers format is used for both command and event messages (Note: some commands are pushed to the ES database by PM).

Here is an example of a command message (protobuf format):

syntax = "proto3";
option csharp_namespace = "VShop.Modules.Sales.Infrastructure.Commands";

import "SharedKernel/SharedKernel.Infrastructure/_schemas/uuid.proto";
import "SharedKernel/SharedKernel.Infrastructure/_schemas/gender.proto";

message SetContactInformationCommand 
{
    Uuid shopping_cart_id = 1;
	string first_name = 2;
	string middle_name = 3;
	string last_name = 4;
	string email_address = 5;
	string phone_number = 6;
	Gender gender = 7;
}

Versioning by Upcasting

The former events are being transformed to their newest versions:

    public MessageEnvelope<TMessage> ToMessage<TMessage>(ResolvedEvent resolvedEvent) where TMessage : IMessage
    {
        object data = _eventStoreSerializer.Deserialize
        (
            resolvedEvent.Event.Data.Span.ToArray(),
            _messageRegistry.GetType(resolvedEvent.Event.EventType)
        );

        object UpcastMessage() => _messageRegistry.TryTransform   // Perform message upcasting.
        (
            resolvedEvent.Event.EventType,
            data,
            out object transformed
        ) ? transformed : data;

        if (UpcastMessage() is not TMessage message) return default;

        MessageMetadata messageMetadata = _eventStoreSerializer.Deserialize
            <MessageMetadata>(resolvedEvent.Event.Metadata.Span.ToArray());

        return new MessageEnvelope<TMessage>
        (
            message,
            messageMetadata.ToMessageContext()
        );
    }

Roadmap

List of features:

NameStatus
Billing ModuleCompleted
Sales ModuleCompleted
Catalog ModuleCompleted
Process Manager ModuleCompleted
Identity ModuleCompleted
Shipping ModuleTODO
Administration ModuleTODO

How to Run

Install .NET 6

Download and install .NET 6 SDK.

Run using Docker Compose

You can run whole application using docker compose from root folder:

docker-compose up

It will create the following services (separate for testing and development):

Technology

List of technologies, frameworks and libraries used for implementation: