Skip to main content

App: API

StarterKit in its basic form is a monolithic web application written in DotNet and React. While it consists of two services, IdentityServer and App, IdentityServer primarily focuses on authentication tasks.

The App API is an AspNetCore API application encapsulating the primary business logic of the system. Furthermore, StarterKit.App.Api essentially acts as a backend-for-frontend, and for deployment simplification, it hosts the Web App.

The architectural framework chosen for the App API is Clean Architecture (also known as Onion Architecture or Hexagonal Architecture). This choice was motivated by its alignment with the Dependency Inversion Principle as well as the principles of Domain-Driven Design (DDD).

Diagram

ASP.NET Core


Database

Structure

StarterKit.App.Api

This project is the entry point for the App API. It includes DI (Dependency Injection) container settings as well as REST controllers, SignalR Hubs and GraphQL endpoints that handle incoming requests. The API is responsible for orchestrating the interactions between the frontend and the backend services.

We have decided to implement GraphQL for executing /GET requests and utilize REST for data modifications.

GraphQL provides a more flexible and efficient way to retrieve data, allowing clients to request only the necessary information and avoid unnecessary queries. This significantly reduces the server load and improves application performance.

At the same time, REST will remain our choice for data modification operations such as create, update, and delete. This approach ensures simplicity and reliability in interacting with our server resources, which is a key aspect in ensuring the stability and security of our application.

We simply ask you to follow this rule without fanaticism. If you need REST /GET endpoints for working with files or for using > ETag, If-Not-Modified-Since, or for any other reasons where it would be impractical to use GraphQL, it is entirely acceptable.

We also expose Swagger and GraphQL Playground for API documentation and testing. This is a convenient way to explore the API and test different queries and mutations.

StarterKit.App.Core.UseCases

It encompasses all interactions within the domain located in StarterKit.App.Core. Commands, Queries, and ReadServices interact with database access services (via repositories) and orchestrate calls to domain entities while performing validation and authorization checks. Ideally, there should be only one aggregate, with others synchronized through events when necessary.

info

Furthermore, Commands, as user interactions, is a single unit capable of invoking UnitOfWork.Commit. However, it is important to note that IConsumer may also execute UnitOfWork.Commit. Nevertheless, from a technical perspective, IConsumer functions exclusively as a continuation of a user-initiated interaction, whether within the same service or elsewhere.

Validation Differences

It's crucial to understand the difference between the validation performed by StarterKit.App.Core and StarterKit.App.Core.UseCases. StarterKit.App.Core handles "action-essential validation, the violation of which leads to state consistency errors", whereas StarterKit.App.Core.UseCases performs checks specific to each use case. In most cases, these would involve access validations and user input validations.

Example: Order Cancellation

An order can be canceled by the client if it hasn't left the warehouse or by the delivery person (for example, if they couldn't contact the client) with a reason provided. Additionally, an order cannot be marked as canceled if it has already been delivered.

In this case, the Order will have two methods: CancelByDeliveryPerson and CancelByClient. These methods will check the order status and throw an error if the order is in an unexpected state.

In turn, the UseCase will validate user input, ensuring that the command or query is in the expected state. For example, if the initiator is a Delivery Person, the Reason should not be null (nevertheless, this doesn't negate the presence of validation at the Aggregate Root level. Validation at the UseCase level allows understanding the contract declared by the command and ensures that the command handler isn't invoked in an invalid state). Additionally, based on the user's role, the UseCase will delegate control to either Order.CancelByDeliveryPerson or Order.CancelByClient.

Aggregate Root Orchestration and Event Synchronization

We mentioned that within a single interaction, it's preferable to avoid orchestrating method calls across multiple Aggregate Roots and instead synchronize them through domain events. This is quite simple to explain (and also applies to method-level validation in the Aggregate Root). The primary reason is to avoid errors and increase the "awareness" requirement for developers who come after you.

  • Orchestration: Suppose that when canceling an order, we need to change the delivery status to "Unsuccessful". We can trigger a domain event "Order Canceled" and update the delivery status. Alternatively, we could call two methods: Order.Cancel and Delivery.Cancel. However, this approach risks an error where a developer might overlook that these two actions must always be called together.

  • Validation Location: If we placed this validation in the UseCase, it would mean that all developers must know that before calling Order.Cancel, they need to check that the order is in the appropriate status. When new conditions or calls arise, they must find all places where cancellation can occur and modify them. This is a significant risk, as developers may forget to update all the necessary places.

info

Sometimes it will be necessary to perform validation between two or more Aggregate Roots. In such cases, we recommend using the concept of Policy. This involves creating a local service with a name ending in Policy, which is passed directly into the Aggregate Root method. However, we advise caution when using services passed into Aggregate Roots and recommend limiting this practice to only Policy services.

By adhering to these principles, we ensure that our system is robust, maintainable, and easier for future developers to work with.

StarterKit.App.Core

StarterKit.App.Core contains the core business logic of the application. The main principles we follow in StarterKit.App.Core include the tactical principles of Domain-Driven Design (DDD) and maintaining maximum independence from other services.

As the main core of our system, it does not use other projects; it only dictates its requirements (through Port services). This is achieved through IoC (Inversion of Control). For example, if we need a service to retrieve an entity from storage, StarterKit.App.Core will declare an interface with all the necessary methods, and StarterKit.App.Infrastructure will provide its implementation (using EntityFrameworkCore, file system access, or a 3rd party service, which is of no concern to StarterKit.App.Core).

StarterKit.App.Infrastructure

The StarterKit.App.Infrastructure project represents the outermost layer. This layer is responsible for implementing infrastructure concerns such as interacting with external systems, accessing the database, handling the file system, etc. It contains concrete implementations of abstractions defined in the StarterKit.App.Core and StarterKit.App.Core.Usecases projects.

The StarterKit.App.Infrastructure project provides concrete implementations of abstractions defined within the StarterKit.App.Core and StarterKit.App.Core.Usecases projects. This may include repositories for data access, adapters for working with external services (e.g., APIs), access management services (e.g., authentication and authorization), as well as tools for logging, caching, etc.

The goal of the Infrastructure project is to provide a convenient and reliable way to access and integrate with external resources, while also ensuring the isolation of business logic from infrastructure implementation details. This contributes to increased flexibility, testability, and maintainability of the application, as well as facilitating the replacement or update of external dependencies without impacting other layers of the architecture.

StarterKit.App.Migrations

This project contains the database schema and data migrations. It is responsible for managing the database schema and ensuring that it remains in sync with the application's requirements. The migrations are created using FluentMigrator and are applied to the database using the dotnet run -- migrate command-line command in src/StarterKit/StarterKit.App.Migrations.

Typical Example

Let's imagine we need to create an Aggregate Root Order with a complete set of CRUD operations. The result of such a task will be the following set of files:

  • StarterKit.App.Api
    • Controllers.Orders/OrderController.cs
    • GraphQL/Orders/OrderQueryGrapgQL.cs
    • GraphQL/Orders/OrderGraphQL.cs
  • StarterKit.App.Core.UseCases
    • Orders/CreateOrderCommand.cs
    • Orders/UpdateOrderCommand.cs
    • Orders/DeleteOrderCommand.cs
    • Orders/OrderReadService.cs
  • StarterKit.App.Core
    • Orders/Order.cs
    • Orders/IOrderRepository.cs
  • StarterKit.App.Infrastructure
    • DataAccess/Repositories/OrderRepositoryAdapter.cs
    • DataAccess/Configurations/OrderConfiguration.cs
  • StarterKit.App.Migrations
    • Migrations/20220101000000_CreateOrderTable.cs

All of this can be easily scaffolded using the Rider Plugin for StarterKit Development.

Appendix I. Domain-Driven Design (DDD) Tactical Patterns

Aggregate Root

Often, the concept of Aggregate Root is not well understood. In tactical DDD, it is the synchronization unit of our domain. This may be somewhat challenging for developers who have only worked with relational databases and their comprehensive transactions, and a bit easier for those who have worked with NoSQL databases like MongoDB, which have transactions at the single-document level. An Aggregate Root, which includes Entities and Value Objects, should be treated as a single cohesive unit (the cohesion is determined by the domain and the appropriateness of the boundaries). All operations are performed on the Aggregate Root, not directly on its entities. This allows for synchronized actions and validations. For example:

Imagine we have an Order that contains OrderItem collection. Additionally, for orders over $100, a 10% discount applies, but the total order value cannot exceed $1000 and cannot be less than $10.

public class Order : IAggregateRoot
{
private List<OrderItem> _items = new();

public Guid Id { get; protected init; }
public OrderStatus Status { get; protected set; }
public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();
public decimal TotalPrice => CalculateTotalPrice();

protected Order()
{
}

protected Order(Guid id, OrderStatus status)
{
Id = id;
Status = status;
}

public static Order New() {
return new Order(Guid.NewGuid(), OrderStatus.Draft);
}

// We can check for duplicates in the order, verify the order status, and calculate the total order amount
public void Add(Guid itemId, int quantity, Money price)
{
AssertNoDuplicate(itemId);
AssertCanModifyItems();
_items.Add(OrderItem.New(itemId, quantity, price));
}

// We can verify the order status, and calculate the total order amount
public void Remove(Guid itemId)
{
AssertCanModifyItems();
_items.RemoveAll(x => x.ItemId == itemId);
}

private Money CalculateTotalPrice()
{
// Here we can calculate the total order amount, applying a 10% discount for orders over $100
}

public void Submit()
{
// Here we can verify that the total order amount is not less than $10 and does not exceed $1000
AssertCanSubmit();
Status = OrderStatus.Submitted;
}
}

// OrderItem can be a Value Object, but for example purposes, we'll treat it as an Entity
public class OrderItem : IEntity
{
// OrderId and ItemId is our Primary Key
public Guid OrderId { get; protected init; }
public Guid ItemId { get; protected init; }
public Money Price { get; protected init; }
public int Quantity { get; protected set; }

public Money TotalPrice => Price * Quantity;

protected OrderItem()
{
}

internal static OrderItem New(Guid itemId, int quantity, Money price)
{
return new OrderItem(Guid.NewGuid(), itemId, quantity, price);
}
}

Entity

Entities are objects that have a unique identifier and can be mutable. They are one of the primary building blocks in our domain model. They have their own set of attributes and can contain ValueObjects.

The only restriction compared to an Aggregate Root is that Entities cannot be changed directly; their modifications must go through the Aggregate Root. However, they can and should have their own methods for modification, but these methods should only be called from within the Aggregate Root. Typically, we declare these methods as internal to prevent accidental invocation from StarterKit.App.Core.UseCases.

Value Object

Value Objects are objects that have no identity and are immutable. They are used to represent concepts that are not entities but are still important to the domain. For example, Money, Address, and Email are all Value Objects. They are used to encapsulate the logic of their respective concepts and ensure that they are always in a valid state.

While Entities allow for direct modification of their attributes, Value Objects, by design, maintain immutability. Instead of modifying a Value Object directly, changes typically involve creating a new instance with the updated values. This approach ensures that existing Value Objects remain unchanged, promoting data consistency and preventing unintended side effects.

The same concept can be either an Entity or a Value Object, depending on the context in which it is used. In the context of a delivery service, a Customer can be a Value Object with fields such as address and the recipient's name. However, in the context of an e-commerce service, a Customer becomes more complex and exhibits clearer characteristics of an Entity, such as:

  • An identifier: We are no longer interested in any customer with the same name and address, but specifically this customer.
  • Mutability: Changes to the customer's name or address do not mean this becomes a different customer for us.

Domain Service

Domain services encapsulate domain logic or operations that do not naturally belong to any specific object. They are responsible for orchestrating complex operations that involve multiple domain objects or require knowledge beyond the scope of individual entities. Domain services promote a cleaner and more cohesive domain model by keeping domain logic centralized and separate from aggregate roots, thereby enhancing modularity, reusability, and testability.

Imagine we have a Task that can be assigned, once assigned single Domain event is fired. And now we need to do batch X tasks reassign, but instead of X events we want to have only one event. Such logic doesn't belong to AggregateRoot/Entity but is still a responsibility of Domain, so we can use Domain services to encapsulate such logic and handle it internally in Domain:

public class Task : IAggregatedRoot
{
...

public void Assign(Guid assigneeId)
{
AssignInternal(assigneeId);
Events.Add(new TaskAssignedEvent(TaskId));
}

// pay attention to internal access modifier
internal void AssignInternal(Guid assigneeId)
{
Asserts.Arg(assigneeId).NotEmpty();
AssigneeId = assigneeId;
}
}

public class TaskBatchAssignmentService : ITaskBatchAssignmentService
{
private readonly IEventDispatcher _eventDispatcher;

public TaskBatchReassignmentService(IEventDispatcher eventDispatcher)
{
_eventDispatcher = eventDispatcher;
}

public void AssignTasks(tasks, Guid assigneeId)
{
foreach (var task in tasks)
{
// use internal assign without events to handle events separately in this class's method
task.AssignInternal(assigneeId);
}

var taskIds = tasks.Select(t => t.TaskId).ToList();
var batchEvent = new TaskBatchAssignedEvent(taskIds, assigneeId);
_eventDispatcher.Dispatch(batchEvent);
}
}

Domain Events

Domain events are typically lightweight, immutable objects that carry relevant information about the event, such as what happened, when it occurred, and any relevant data associated with the event. They are raised by entities, aggregate roots, or domain services as a result of certain actions or conditions being met.

Domain events play a crucial role in enabling loose coupling and ensuring consistency between different parts of the system. They allow for asynchronous communication between bounded contexts, facilitating eventual consistency and scalability.

StarterKit features built-in support for domain events. Each entity and aggregate root has a collection of events that will be fired as soon as the UnitOfWork's Commit method is called. Domain events in StarterKit are categorized into synchronous (built on MediatR) and asynchronous (built on a combination of MediatR and MassTransit with ServiceBus transport) types.

For more reliable handling of asynchronous events, MassTransit is configured to use the Inbox/Outbox patterns, allowing you to not worry about the transactionality of sending messages to the queue relative to the database transaction execution.