Skip to content

Core Concepts

This article introduces the core concepts of the MiCake framework to help you better understand and use it.

MiCake adopts a modular design; an application consists of multiple modules. Each module is an independent functional unit that can:

  • Configure its own services
  • Manage its own lifecycle
  • Declare dependencies on other modules

Each module has explicit lifecycle hooks:

public class MyModule : MiCakeModule
{
// 1. Configure services phase
public override void ConfigureServices(ModuleConfigServiceContext context)
{
// Register services into the DI container
context.Services.AddScoped<IMyService, MyService>();
}
// 2. Application initialization phase
public override void OnApplicationInitialization(ModuleInitializationContext context)
{
// Initialization logic when the application starts
var logger = context.ServiceProvider.GetService<ILogger>();
logger.LogInformation("Module initialized");
}
// 3. Application shutdown phase
public override void OnApplicationShutdown(ModuleShutdownContext context)
{
// Cleanup logic when the application shuts down
}
}

Use the [RelyOn] attribute to declare dependencies between modules:

[RelyOn(typeof(MiCakeAspNetCoreModule))]
[RelyOn(typeof(MiCakeEntityFrameworkCoreModule))]
public class MyAppModule : MiCakeModule
{
// The framework ensures dependent modules are initialized first
}

MiCake implements the core components of DDD tactical patterns:

  1. Entity: an object with a unique identity
  2. Value Object: an immutable object compared by property values
  3. Aggregate Root: the root entity of an aggregate
  4. Repository: provides persistence for aggregate roots
  5. Domain Event: captures business events
  6. Domain Service: encapsulates domain logic

An aggregate is a collection of related objects, accessed through the aggregate root:

┌─────────────────────────────────┐
│ Aggregate (Order) │
│ ┌──────────────────────────┐ │
│ │ Aggregate Root (Order) │◄──┼─── External access only through the aggregate root
│ │ - OrderId │ │
│ │ - Customer │ │
│ │ - Status │ │
│ └──────────────────────────┘ │
│ │ manages │
│ ▼ │
│ ┌──────────────────────────┐ │
│ │ Entity (OrderItem) │ │
│ │ - ProductId │ │
│ │ - Quantity │ │
│ │ - Price │ │
│ └──────────────────────────┘ │
└─────────────────────────────────┘

Entity characteristics:

  • Has a unique identity (Id)
  • Mutable
  • Equality is compared by Id
  • Has a lifecycle
public class Order : AggregateRoot<int>
{
public int Id { get; init; } // Unique identity
public string OrderNumber { get; private set; }
// ...
}

Value Object characteristics:

  • No unique identity
  • Immutable
  • Equality is compared by all property values
  • Can be replaced
public class Address : ValueObject
{
public string Street { get; }
public string City { get; }
public string ZipCode { get; }
public Address(string street, string city, string zipCode)
{
Street = street;
City = city;
ZipCode = zipCode;
}
protected override IEnumerable<object> GetEqualityComponents()
{
yield return Street;
yield return City;
yield return ZipCode;
}
}

A repository encapsulates data access logic and provides a collection-like interface:

public interface IRepository<TAggregateRoot, TKey>
{
Task<TAggregateRoot> FindAsync(TKey id);
Task AddAsync(TAggregateRoot aggregateRoot);
Task AddAndGetIdAsync(TAggregateRoot aggregateRoot);
Task UpdateAsync(TAggregateRoot aggregateRoot);
Task DeleteAsync(TAggregateRoot aggregateRoot);
Task DeleteByIdAsync(TKey id);
}

Repositories no longer own persistence: repository methods only modify the tracking state of the Unit of Work; data is committed by IUnitOfWork.CommitAsync().

❌ Wrong approach:

// Do not create repositories for internal entities
public interface IOrderItemRepository : IRepository<OrderItem, int>
{
}

✅ Correct approach:

// Create repositories only for aggregate roots
public interface IOrderRepository : IRepository<Order, int>
{
}
// Access internal entities through the aggregate root
var order = await orderRepository.FindAsync(orderId);
var items = order.Items; // Access through the aggregate root

Domain events capture important business events that occur in the domain:

// 1. Define the event
public class OrderPlacedEvent : IDomainEvent
{
public int OrderId { get; }
public decimal TotalAmount { get; }
public OrderPlacedEvent(int orderId, decimal totalAmount)
{
OrderId = orderId;
TotalAmount = totalAmount;
}
}
// 2. Raise the event in the aggregate root
public class Order : AggregateRoot<int>
{
public void PlaceOrder()
{
// Business logic
Status = OrderStatus.Placed;
// Raise the domain event
RaiseDomainEvent(new OrderPlacedEvent(Id, TotalAmount));
}
}
// 3. Handle the event
public class OrderPlacedEventHandler : IDomainEventHandler<OrderPlacedEvent>
{
public Task HandleAsync(OrderPlacedEvent domainEvent, CancellationToken cancellationToken)
{
// Send email notification
// Update inventory
// Write logs
return Task.CompletedTask;
}
}

Domain events are dispatched automatically when the unit of work commits (IUnitOfWork.CommitAsync()):

var order = Order.Create(customer);
order.PlaceOrder(); // Raises the event, but does not dispatch immediately
await repository.AddAsync(order);
await unitOfWork.CommitAsync(); // All events are dispatched here

The Unit of Work pattern is used to:

  • Track all changes during a business operation
  • Ensure changes are committed as a single transaction
  • Guarantee data consistency

MiCake provides several ways to start a unit of work:

using MiCake.AspNetCore.Uow;
[ApiController]
[Route("api/[controller]")]
public class OrderController : ControllerBase
{
private readonly IRepository<Order, int> _orderRepository;
private readonly IRepository<Product, int> _productRepository;
[HttpPost]
[UnitOfWork] // Starts a unit of work automatically
public async Task<IActionResult> CreateOrder([FromBody] CreateOrderDto dto)
{
// 1. Create the order
var order = Order.Create(dto.CustomerId, dto.ShippingAddress);
foreach (var item in dto.Items)
{
order.AddItem(item.ProductId, item.ProductName, item.Price, item.Quantity);
}
await _orderRepository.AddAsync(order);
// 2. Update product inventory
foreach (var item in dto.Items)
{
var product = await _productRepository.FindAsync(item.ProductId);
product.DecreaseStock(item.Quantity);
}
// When the method returns normally, the UoW commits the transaction automatically
// If an exception is thrown, the transaction is rolled back automatically
return Ok(order.Id);
}
}

For concrete examples, read the Unit of Work documentation.

MiCake supports automatic service registration through interface markers:

// Marked as a transient service
public class MyService : ITransientService
{
// Automatically registered with the Transient lifetime
}
// Marked as a scoped service
public class OrderService : IScopedService
{
// Automatically registered with the Scoped lifetime
}
// Marked as a singleton service
public class CacheService : ISingletonService
{
// Automatically registered with the Singleton lifetime
}

Use the [InjectService] attribute to precisely control registration:

[InjectService(typeof(IMyService), ServiceLifetime.Scoped)]
public class MyService : IMyService
{
// ...
}
  • Keep aggregates small and focused
  • Access internal entities through the aggregate root
  • Ensure consistency within aggregate boundaries
  • Communicate across aggregates via domain events
  • Create repositories only for aggregate roots
  • Repository operations should be atomic
  • Complete related operations within a single unit of work
  • Avoid exposing data access details via IQueryable, because every method in a repository should express a clear business intent
  • Domain services: core business logic
  • Application services: coordinate operations across multiple aggregates
  • Infrastructure services: technical services (caching, logging, etc.)

Now that you understand the core concepts of MiCake, you can dive deeper into each specific component: