Skip to content
StarLive demo →
ModuleChecked against main · 31 Oct 20267 min read

CRM module

The CRM module stores each company's customers with EF Core writes and Dapper reads, reports new-customer statistics, and publishes the events Booking turns into attendees.

Owns
Each company’s customers
Schema
crm, one table: Customers
Endpoints
6, each behind a customers permission
Feeds
Booking’s attendees, through three events

CRM is the smallest business module: one aggregate, its CRUD slices, a statistics query and demo data.

Feature Folder What it does
Customer Customers/ Customer: name, optional date of birth, gender and email, soft-deleted.
Write slices RegisterCustomer/, UpdateCustomer/, DeleteCustomer/ Each loads or creates the aggregate with EF Core, calls a domain method and saves.
Read slices GetCustomers/, GetCustomerById/ Dapper queries through CustomersRepository, always filtered by CompanyId.
New-customer stats GetNewCustomerStats/ Registrations per day, week or month in a time zone, with an optional comparison period.
Demo data SeedDemoCustomers/, TopUpDemoCustomers/ Fake customers for a new demo company, plus a daily top-up.
Data access Customers/Database/ CustomerTypeConfiguration, CustomerFilters, CustomersRepository.
Contracts AppointMe.Crm.Contracts Three customer events and ICustomerRehydrationSource.

The repo’s CLAUDE.md uses its Customers/ folder as the example slice layout. The split between EF Core writes and Dapper reads is explained in CQRS: EF writes, Dapper reads, and the slice layout in Vertical slices.

  • Directorysrc/CRM/
    • DirectoryAppointMe.Crm/
      • DirectoryAuthorization/ # CrmDefaultGrantPolicy
        • …
      • DirectoryConfiguration/ # AddCrmModule
        • …
      • DirectoryCustomers/
        • DirectoryDatabase/ # EF config, filters, Dapper repository
          • …
        • DirectoryDeleteCustomer/
          • …
        • DirectoryGetCustomerById/
          • …
        • DirectoryGetCustomers/
          • …
        • DirectoryGetNewCustomerStats/
          • …
        • DirectoryRegisterCustomer/
          • …
        • DirectorySeedDemoCustomers/
          • …
        • DirectoryTopUpDemoCustomers/
          • …
        • DirectoryUpdateCustomer/
          • …
        • Customer.cs
        • CustomerPermissions.cs
        • …
      • DirectoryDatabase/ # CrmDbContext, migrations
        • …
    • DirectoryAppointMe.Crm.Contracts/
      • DirectoryCustomers/ # events, ICustomerRehydrationSource
        • …
    • DirectoryAppointMe.Crm.Tests/
      • …

Six endpoints, each behind one customers permission and scoped to the company in X-Company-Id.

Method Route Permission Purpose
GET /api/v1/customers customers:view Paged list, newest first. search matches every word against name and email. page and limit default to 1 and 10, and limit is capped at 100.
GET /api/v1/customers/{id} customers:view One customer. 404 if it’s deleted or belongs to another company.
POST /api/v1/customers customers:create Body { firstName, lastName?, dateOfBirth?, gender?, email? }. Returns 201 with the new id.
PUT /api/v1/customers/{id} customers:update Same body as create. Replaces every field and returns 204.
DELETE /api/v1/customers/{id} customers:delete Soft delete. Booking soft-deletes the matching attendee.
GET /api/v1/crm/dashboard/new-customers customers.statistics:view Query From, To, Bucket (day, week or month), TimeZone, and optionally CompareFrom with CompareTo. Returns totals and one bucket per period.

gender accepts Male, Female or Other, in any case. Names are title-cased and emails lower-cased by the shared value objects.

Five permissions in CustomerPermissions: every role can view customers, and only Owner can delete them.

Permission Key Owner Manager Staff Receptionist
CustomerPermissions.View customers:view ✓ ✓ ✓ ✓
CustomerPermissions.Create customers:create ✓ ✓ — ✓
CustomerPermissions.Update customers:update ✓ ✓ — ✓
CustomerPermissions.Delete customers:delete ✓ — — —
CustomerPermissions.ViewStatistics customers.statistics:view ✓ ✓ — —

The defaults are in CrmDefaultGrantPolicy. A company can override them for every role except Owner in the Organizations permission settings.

CRM publishes three customer events that Booking consumes, and consumes no other module’s events.

Event Raised by Handled by
CustomerRegisteredEvent Customer.Register, from the API and from demo seeding Booking’s CustomerRegisteredEventHandler creates the attendee.
CustomerUpdatedEvent customer.Update Booking’s CustomerUpdatedEventHandler updates the attendee.
CustomerDeletedEvent customer.Delete Booking’s CustomerDeletedEventHandler soft-deletes the attendee.

The factory raises the event on the aggregate, and Wolverine publishes it when the handler saves the change:

src/CRM/AppointMe.Crm/Customers/RegisterCustomer/RegisterCustomer.csView source ↗
public static Customer Register(CompanyId companyId, PersonName name, DateOfBirth? dateOfBirth,
Gender? gender, Email? email, DateTimeOffset registrationDate)
{
var customer = new Customer
{
Id = new CustomerId(NewId()),
CompanyId = companyId,
Name = name,
DateOfBirth = dateOfBirth,
Gender = gender,
Email = email,
RegistrationDate = registrationDate,
IsDeleted = false
};
customer.Raise(new CustomerRegisteredEvent(
customer.Id.Value, companyId.Value,
name.FirstName, name.LastName, dateOfBirth?.Value, email?.Value));
return customer;
}

CRM does handle two commands. SeedDemoCustomersCommand comes from the API host’s DemoSeedingSaga, and TopUpDemoCustomersCommand from its own job. Booking also reads customers directly through ICustomerRehydrationSource.GetByCompanyAsync, a paged query for its reconciliation job. The event flow is covered in Domain events & messaging.

One table, one demo-only job, and a registration method that reads no configuration.

Table Entity Query filters Notes
crm.Customers Customer SoftDelete, CompanyId Index on (CompanyId, RegistrationDate) where not deleted. Computed SearchKey: lower-cased first name, last name and email. Version is a row version.
Job id Schedule What it does
crm:top-up-demo-customers 0 3 * * * (daily, 03:00) Only when Demo:Enabled is on. For every company from ICompanyRehydrationSource, invokes TopUpDemoCustomersCommand(2), which adds two customers registered now.

The registrar only schedules the job when demo mode is on:

src/CRM/AppointMe.Crm/Customers/TopUpDemoCustomers/TopUpDemoCustomersJobRegistrar.csView source ↗
private const string JobId = "crm:top-up-demo-customers";
private const string CronExpression = "0 3 * * *"; // daily at 03:00 UTC
public void Register(IRecurringJobScheduler scheduler)
{
if (!configuration.GetValue<bool>("Demo:Enabled"))
{
return;
}
scheduler.AddOrUpdate<TopUpDemoCustomersJob>(
JobId,
job => job.Run(CancellationToken.None),
CronExpression);
}

AddCrmModule() registers CrmDbContext (schema crm), the endpoints, CustomersRepository, CustomerRehydrationSource, the demo seeder and top-up job, the permissions and the recurring jobs. The demo saga asks for 150 seeded customers, and SeedDemoCustomersCommandHandler skips a company that already has any. How recurring jobs run: Background jobs.

Why soft-delete customers?1 min · optional

Booking keeps a customer’s appointments after the customer is gone. CustomerDeletedEvent soft-deletes the matching attendee, and AppointmentsRepository joins attendees without an IsDeleted check, so past appointments still show the name.

A hard delete would make those appointments vanish from the calendar, because the calendar query uses an INNER JOIN on attendees.

A new customer field touches the aggregate, both slices, the read model and, if Booking needs it, the events.

Change Start here
Add a field, such as a phone number Customer and CustomerTypeConfiguration, the request and command in RegisterCustomer/ and UpdateCustomer/, then CustomerRecord, the SQL in CustomersRepository and CustomerDto. Add a migration and regenerate the API client.
Send the field to Booking Add it to CustomerRegisteredEvent, CustomerUpdatedEvent and CustomerSnapshot in AppointMe.Crm.Contracts, then to Booking’s Attendee. See Cross-module events.
Search on another column The SearchKey computed-column SQL in CustomerTypeConfiguration, plus a migration.
Add a second aggregate A feature folder next to Customers/, a DbSet and filters in CrmDbContext, a *Permissions class and its grants in CrmDefaultGrantPolicy. See Add an endpoint.
Drop the demo top-up Set Demo:Enabled to false before the first start. The registrar never removes a job, so one Hangfire already stored stays scheduled until you delete it on the dashboard at /admin/jobs.

AppointMe.Crm.Tests covers the domain methods, the demo seeder and the statistics, but not updating a customer or the Dapper repository.

Test class Covers
RegisterCustomerTests Customer.Register sets the state and raises CustomerRegisteredEvent.
DeleteCustomerTests customer.Delete sets IsDeleted and raises CustomerDeletedEvent.
SeedDemoCustomersTests Seeded customers carry the registration date they’re given.
NewCustomerStatsCalculatorTests Day buckets in the company’s time zone, empty buckets, and compare buckets only when asked.
GetNewCustomerStatsQueryHandlerTests The handler throws AccessDeniedException without customers.statistics:view.
Terminal window
dotnet test src/CRM/AppointMe.Crm.Tests