Core Services

Status: DRAFT v0.1 — part of the two-tier pattern.

A core service owns a slice of the domain's data. It is the system of record for one or two aggregate roots, and almost nothing else. Core services are the foundation the orchestrators build processes on top of.

The defining trait is restraint: a core service is light on process and heavy on data. It does CRUD — create, read, update, delete — with validation, over a database it alone owns. It does not run business processes, does not call other core services, and does not know why it is being called. That discipline is what makes the whole fleet composable.


What a core service is

Think of a core service as a well-guarded table (or small set of tables) with a typed, validated, topic-addressed API in front of it.


The CRUD surface

A core service exposes its aggregate as a small, predictable set of topics (core-concepts.md), one per operation. The steer is a consistent naming shape across the fleet — aggregate:operation — so that every service in the estate reads the same way:

Operation Example topic Request → Result
Create tenant:create CreateTenantTenantCreated (or validation)
Read tenant:get GetTenant (by id) → Tenant (or not-found)
Update tenant:update UpdateTenantTenantUpdated (or validation / not-found)
Delete tenant:delete DeleteTenant (by id) → Deleted (or not-found)
List / query tenant:list ListTenants (filter) → TenantPage

Each topic is served by a message handler — handle : TRequest -> Result<TResponse> (core-concepts.md). Because the topics and their request/response types are registered in the handler registry, the service's spec is derived, not hand-written (Cloud Service Profile R5): the CRUD surface documents itself, and clients can be generated from it.

(informative, .NET) A create handler is an ordinary handler with validation in front of it:

[Message("tenant:create")]
public class CreateTenantHandler : IMessageHandler<CreateTenant, TenantCreated>
{
    private readonly ITenantStore _store;   // this service's own database, nobody else's
    public CreateTenantHandler(ITenantStore store) => _store = store;

    public async Task<IBenzeneResult<TenantCreated>> HandleAsync(CreateTenant message)
    {
        var tenant = Tenant.New(message.CompanyName);
        await _store.InsertAsync(tenant);
        return BenzeneResult.Ok(new TenantCreated { TenantId = tenant.Id });
    }
}

Validation is a middleware step in front of the handler (e.g. FluentValidation), so an invalid CreateTenant short-circuits to a validation result and never reaches the store — see the FluentValidation/DataAnnotations integrations in the language port. The handler itself stays a clean function of request-to-result.


Reference by id

When one aggregate needs to point at another, it stores the id of the other aggregate — never an embedded copy, and never a foreign-key join across service boundaries (there is no shared database to join in).

A User belongs to a Tenant. The User aggregate carries a tenantId: string. It does not carry a Tenant object, and the user service does not join to a tenant table — it holds the id and nothing more.

Consequences that are features, not limitations:


Directional dependencies

References point one way, and the direction is always child → parent: the child knows the parent; the parent does not know the child.

  Tenant  (parent — knows nothing about users)
    ▲
    │  User.tenantId  (child holds the parent's id)
    │
  User    (child — knows its tenant)

This keeps the dependency graph acyclic. A service can only ever depend "downward" on services that own the aggregates it references, and never the reverse. Acyclicity is what lets you deploy the tenant service without touching the user service, delete the user service without the tenant service caring, and reason about blast radius by following arrows in one direction.

If you find yourself wanting the parent to know about its children (the tenant needing to list its users), that is a read model / query concern and it belongs above the core layer — an orchestrator or a dedicated read service composes "a tenant and its users" by asking both services; the tenant core service still does not grow a dependency on the user service. Cross-cutting queries across aggregates are a fleet-level concern the mesh and read models serve, not a reason to break the directional rule.


A core service is a Benzene Cloud Service

Nothing about a core service is special Benzene — it is the ordinary, recommended shape at the top of the adoption ladder. Aim each core service at the Cloud Service Profile so the fleet tooling works on it with no negotiation:

A core service typically needs no orchestration and no saga of its own: each of its writes is a single write to a single database, atomic on its own. Atomicity across services is the orchestrator's problem, not the core service's — which is exactly why the core service can stay so simple.


Checklist

A service is a well-formed core service when:

Next: the layer that turns these building blocks into business processes — orchestrators.