App Main: Backend
StarterKit in its basic form is a monolithic web application written in DotNet and React. While it consists of two services, Identity and App, Identity primarily focuses on authentication tasks.
The App Main API is an AspNetCore API application encapsulating the primary business logic of the system. Furthermore, StarterKit.App.Main.Api essentially acts as a backend-for-frontend, and for deployment simplification, it hosts the Web App.
The architectural framework chosen for the App Main 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).
Beyond being a classic web API, StarterKit.App.Main acts as a Host application to which pre-built mod-* packages can be plugged in. Each mod is a self-contained module that provides application logic for a specific business task out of the box, for example:
mod-schedule- Scheduling and appointmentsmod-tasks- Task managementmod-assessments- Assessmentsmod-billing- Billing, invoicing, and paymentsmod-coverages- Insurance coverage and interaction with payer APIs
Mods are consumed as NuGet and NPM packages across the relevant layers (*.Api, *.Core.Main, *.Infrastructure). This allows the application to extend its capabilities without building common functionality from scratch, letting the team focus on business logic that is specific to this product.
Diagram
ASP.NET Core
Structure
StarterKit.App.Main.Api
This project is the entry point for the App Main API. It includes DI (Dependency Injection) container settings as well as REST controllers, GraphQL types, queries, DataLoaders, and SignalR hubs that handle incoming requests.
Controllers in this project are used primarily for mutations (create, update, delete). They must be kept as thin as possible: their sole responsibility is to receive the HTTP request and immediately delegate control to StarterKit.App.Main.Interactions via a MediatR command or a direct call to an interaction service.
Lightweight read endpoints may live in controllers too, when GraphQL is impractical — e.g., when working with file downloads, ETags, or
If-Modified-Sincesemantics. Follow the "without fanaticism" spirit from the rule above.
GraphQL types, queries, and DataLoaders live under the GraphQL/ folder. In addition, this project contains GraphQL extension types used to extend return types declared inside mod-* packages, enabling app-main-specific fields to be appended to mod entities without modifying the mod itself.
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.
Links:
StarterKit.App.Main.Api.Public
This project exposes GraphQL queries for unauthenticated users (e.g., patient-facing portals, public intake forms — organizations, providers, specialties). It contains no REST controllers; all endpoints are read-only GraphQL queries that delegate immediately to IPublicReadService from StarterKit.App.Main.Interactions.
StarterKit.App.Main.Interactions
This project contains all interactions — every way a user or external system can interact with the application. Concretely, it holds:
- MediatR Command handlers — for mutation operations (create, update, delete).
- ReadServices — for query/read operations, providing authorized access to data.
The primary responsibilities of this layer are:
- Orchestration — coordinating calls across different Aggregate Roots when a single interaction touches more than one Aggregate Root.
- Authorization — validating that the current user has the required permissions to perform the requested action, and that the requested resource belongs to their organization/scope.
- Transaction — every interaction is a transaction boundary. It is responsible for calling
IUnitOfWork.Commitat the end of the interaction to ensure that all changes are saved atomically. - Input validation — validating that the incoming parameters are consistent with the intended interaction (e.g., a required field for a specific role is present). Note that this is different from domain invariant validation (data-consistency validation), which belongs in Core.
Commands, as user interactions, are the single units that invoke UnitOfWork.Commit. IConsumer (message consumer) may also call UnitOfWork.Commit, but from a conceptual standpoint it is always a continuation of a user-initiated interaction — within the same service or across services.
Interactions must be as thin as possible. Business logic must not live here; it belongs in Aggregate Roots, Domain Services, and Value Objects inside the Core projects. The interaction layer's job is orchestration and coordination, not computation.
Validation Differences
It's crucial to understand the difference between validation in the Core projects and in Interactions. Core projects handle domain invariant validation — checks whose violation would result in an inconsistent domain state. Interactions perform use-case validation — checks specific to a given command or query, such as access control and input contract verification.
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.
The Interaction will validate user input: for example, if the initiator is a Delivery Person, Reason must not be null. Based on the user's role, the Interaction will then delegate to Order.CancelByDeliveryPerson or Order.CancelByClient.
Aggregate Root Orchestration and Event Synchronization
Within a single interaction it is preferable to avoid orchestrating calls across multiple Aggregate Roots. Instead, secondary side-effects should be triggered through domain events. The primary reasons:
-
Orchestration risk: If when canceling an order we must also cancel the associated delivery, calling
Order.CancelandDelivery.Canceltogether in the Interaction means every developer working on cancellation logic must know both calls are always required. A domain event ("Order Canceled") makes the coupling explicit and enforced at the domain level. -
Scattered validation: Placing cross-aggregate checks in the Interaction means every new cancellation flow must independently rediscover and replicate those checks, increasing the risk of omission.
Sometimes validation genuinely requires knowledge of two or more Aggregate Roots. In such cases, use the Policy pattern: create a focused service whose name ends in Policy and pass it into the Aggregate Root method. Limit service injection into Aggregate Roots to Policy services only.
Adapting mod-* behavior via Decorators
Mods deliberately contain no authorization logic — that is always the host application's responsibility. StarterKit.App.Main.Interactions adapts mod behavior by registering decorators over the interactor interfaces each mod exposes (e.g., ITaskInteractor, IAppointmentInteractor).
A decorator wraps the mod's own implementation, intercepts every method call, and can:
- Add authorization — validate that the current user has the required role and that the requested resource belongs to their organization/scope.
- Restrict functionality — block operations that should not be accessible in this application context.
- Extend functionality — enrich inputs or outputs before/after delegating to
_next(e.g.,AppointmentInteractorTelehealthLocationDecoratorautomatically resolves and injects the telehealth location before forwarding the create call to the mod).
internal class TaskInteractorAuthorizationDecorator : ITaskInteractor<Task>
{
private readonly ITaskInteractor<Task> _next;
private readonly IAuth _auth;
public async Task<Task> UpdateAsync<TInput>(string id, TInput input)
{
await ValidateMutationAsync(id); // authorization check
return await _next.UpdateAsync(id, input); // delegate to mod
}
}
Decorators are registered in AppInteractionsModule using Scrutor's .Decorate<>() so the mod never needs to know about the host's authorization rules.
Links:
StarterKit.App.Main.Core.Main
StarterKit.App.Main.Core.Main contains the domain models, Aggregate Roots, Entities, Value Objects, and Domain Services for everything that is not a medical record — organizations, users, employees, patients, providers, scheduling, tasks, notifications, billing, and more.
This is the heart of the application. All other projects may depend on it, but it must never depend on Api, Interactions, or Infrastructure. It declares repository interfaces (ports) that Infrastructure implements, and it contains all business rules and invariant validations. Interactions call methods on Aggregate Roots and Domain Services defined here to execute business logic.
It depends on StarterKit.App.Main.Core for shared base types and on mod-* packages for cross-cutting domain contracts.
Links:
StarterKit.App.Main.Core.MedicalRecord
StarterKit.App.Main.Core.MedicalRecord contains the domain models, Aggregate Roots, Entities, Value Objects, and Domain Services for medical records — diagnoses, lab results, vital signs, procedures, consents, assessments, and so on.
The same rules apply as for StarterKit.App.Main.Core.Main: it is a pure domain project that declares its own repository interfaces and keeps all medical-record business logic encapsulated. It depends on StarterKit.App.Main.Core for shared base types.
Links:
StarterKit.App.Main.Core
StarterKit.App.Main.Core is the shared base for the Core layer. It is not a domain module in its own right; rather, it provides common building blocks consumed by both Core.Main and Core.MedicalRecord:
- Shared interfaces (e.g.,
IAuth,IHasOrganizationId,IHasPatientId) - Common value objects (e.g.,
Email,Phone,Price) - Shared enumerations and error codes
- Base extension methods
Any concept that needs to be referenced by both Core.Main and Core.MedicalRecord — without either one depending on the other — belongs here.
StarterKit.App.Main.Core.Connect
StarterKit.App.Main.Core.Connect is not a domain module — it is a bridge layer that enables cross-domain interactions between Core.Main, Core.MedicalRecord, and mod-* packages without creating a direct dependency between them.
It contains adapter and connection classes (e.g., AppointmentEncounterDomainConnection, PatientMedicalRecordPolicy) that are wired up through IoC so that Core.Main and Core.MedicalRecord can collaborate through well-defined interfaces without knowing about each other directly.
StarterKit.App.Main.Infrastructure
StarterKit.App.Main.Infrastructure is the outermost layer. It is responsible for all I/O concerns:
- Repository implementations — concrete EF Core
RepositoryAdapterclasses that implement the repository interfaces declared inCore.MainandCore.MedicalRecord. IUnitOfWorkimplementation — wraps the EF CoreDbContextandSaveChangesAsync.- ACL (Anti-Corruption Layer) — adapters for external systems and third-party libraries (e.g., Azure OpenAI, Twilio, MassTransit, Chatify, Wispo, Clippo, Flippo).
Infrastructure depends on the Core projects (as a consumer of their interfaces) and on Interactions (to wire up MediatR consumers and other integration points). It must never contain business logic.
Links:
StarterKit.App.Main.Migrations
This project contains the EF Core database migrations. It is responsible for managing the database schema and ensuring that it remains in sync with the application's domain model. Migrations follow the FluentMigrator-compatible naming convention and are applied via the migration runner on application startup or via a dedicated CLI command.
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.Main.Api
Controllers.Orders/OrderController.csGraphQL/Orders/OrderQueryGraphQL.csGraphQL/Orders/OrderGraphQL.csGraphQL/Orders/OrderGraphQLDataLoader.cs
- StarterKit.App.Main.Interactions
Orders/CreateOrderCommand.csOrders/UpdateOrderCommand.csOrders/DeleteOrderCommand.csOrders/OrderReadService.cs
- StarterKit.App.Main.Core.Main
Orders/Order.csOrders/IOrderRepository.cs
- StarterKit.App.Main.Infrastructure
DataAccess/Repositories/OrderRepositoryAdapter.csDataAccess/Configurations/OrderConfiguration.cs
- StarterKit.App.Main.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.