Build your first feature
Build a .NET 10 vertical slice end to end, notes on a CRM customer, with an EF Core entity, a permission, a migration, a Dapper read query and a generated React Query hook.
- You'll build
- Notes on a customer, from table to UI
- Time
- About 45 minutes
- Touches
- The CRM module and the customer profile page
- Before you start
- Quick start done, stack running
What you’ll build
Section titled “What you’ll build”Team members write notes on a customer and read them, newest first, on the customer’s profile page. The feature touches every layer of a slice once. Every snippet on this page was built, type-checked and tested on top of commit 2ccbe87.
| Layer | You add | Steps |
|---|---|---|
| Domain | A CustomerNote aggregate, a CustomerNoteId, the LongString value object |
1–2 |
| Write model | An EF Core mapping, a named tenant filter, a migration | 3–4 |
| Authorization | A CustomerNotePermissions class and default grants |
5 |
| API | An AddCustomerNote command slice, a GetCustomerNotes query slice, a Dapper repository |
6–8 |
| Frontend | The regenerated client, two permission labels, a notes section | 9–11 |
The CRM module before, showing only the parts you touch:
Directorysrc/CRM/AppointMe.Crm/
DirectoryAuthorization/
- CrmDefaultGrantPolicy.cs
DirectoryConfiguration/
- CrmModule.cs
DirectoryCustomers/ # the Customer aggregate and its slices
- Customer.cs
- CustomerId.cs
- CustomerPermissions.cs
DirectoryRegisterCustomer/
- …
- …
DirectoryDatabase/
- CrmDbContext.cs
DirectoryMigrations/
- …
And after. New files are bold:
Directorysrc/CRM/AppointMe.Crm/
DirectoryAuthorization/
- CrmDefaultGrantPolicy.cs # grants the new permissions
DirectoryConfiguration/
- CrmModule.cs # registers the repository
DirectoryCustomerNotes/ # new feature folder, next to Customers/
- CustomerNote.cs
- CustomerNoteId.cs
- CustomerNoteDto.cs
- CustomerNotePermissions.cs
DirectoryAddCustomerNote/ # new command slice: factory, command, handler, endpoint, request, response
- …
DirectoryGetCustomerNotes/ # new query slice: query, handler, endpoint, response
- …
DirectoryDatabase/ # new: CustomerNoteFilters, CustomerNoteTypeConfiguration, CustomerNotesRepository
- …
DirectoryCustomers/ # unchanged
- …
DirectoryDatabase/
- CrmDbContext.cs # adds the DbSet and the tenant filter
DirectoryMigrations/
- …_AddCustomerNotes.cs # generated by dotnet ef
Model and store the note
Section titled “Model and store the note”The note is its own aggregate in its own feature folder, with a typed ID, a validated text value object and a named tenant filter.
-
Add the ID and the aggregate. Create
CustomerNotes/next toCustomers/. The ID wraps aGuidthe wayCustomerIddoes, and the aggregate records which company and which customer the note belongs to.src/CRM/AppointMe.Crm/CustomerNotes/CustomerNoteId.cs (new) namespace AppointMe.Crm.CustomerNotes;public readonly record struct CustomerNoteId(Guid Value);src/CRM/AppointMe.Crm/CustomerNotes/CustomerNote.cs (new) using AppointMe.Crm.Customers;namespace AppointMe.Crm.CustomerNotes;public sealed record CustomerNote : AggregateRoot{public required CustomerNoteId Id { get; init; }public required CompanyId CompanyId { get; init; }public required CustomerId CustomerId { get; init; }public required LongString Text { get; init; }public required DateTimeOffset CreatedAt { get; init; }}LongStringis the shared value object for free text up toLongString.MaxLength(4,000) characters.AggregateRoot,CompanyIdandLongStringneed nousing, because the module’sGlobalUsings.csimports their namespaces. -
Write the factory. Domain operations are extension methods in the slice that owns them, so
CustomerNote.Addlives inAddCustomerNote/, the wayCustomer.Registerlives inRegisterCustomer/.src/CRM/AppointMe.Crm/CustomerNotes/AddCustomerNote/AddCustomerNote.cs (new) using AppointMe.Crm.Customers;namespace AppointMe.Crm.CustomerNotes.AddCustomerNote;public static class AddCustomerNote{extension(CustomerNote){public static CustomerNote Add(CompanyId companyId, CustomerId customerId, LongString text,DateTimeOffset createdAt){return new CustomerNote{Id = new CustomerNoteId(NewId()),CompanyId = companyId,CustomerId = customerId,Text = text,CreatedAt = createdAt};}}}The factory takes a
LongString, not astring, so it can’t receive unvalidated text. The handler in step 6 builds it withLongString.Create, which throwsValidationExceptionfor blank text or text over 4,000 characters.NewId()returns a version 7Guid. -
Map it in EF Core and scope it to the tenant. Add a type configuration in
CustomerNotes/Database/. TheApplyConfigurationsFromAssemblycall inCrmDbContextpicks it up.src/CRM/AppointMe.Crm/CustomerNotes/Database/CustomerNoteTypeConfiguration.cs (new) builder.ToTable("CustomerNotes");builder.HasKey(note => note.Id);// …builder.Property(note => note.Text).HasConversion(text => text.Value, value => new LongString(value)).HasMaxLength(LongString.MaxLength).IsRequired();builder.Property(note => note.CreatedAt).IsRequired();builder.HasOne<Customer>().WithMany().HasForeignKey(note => note.CustomerId).OnDelete(DeleteBehavior.Restrict);builder.HasIndex(note => new { note.CustomerId, note.CreatedAt });The cut lines map the three IDs the way
CustomerTypeConfigurationdoes, for example.HasConversion(id => id.Value, value => new CustomerNoteId(value)). The index starts withCustomerId, so EF reuses it for the foreign key instead of adding a second one. Next, name the tenant filter and register it in the context:src/CRM/AppointMe.Crm/CustomerNotes/Database/CustomerNoteFilters.cs (new) namespace AppointMe.Crm.CustomerNotes.Database;public static class CustomerNoteFilters{public const string CompanyId = nameof(CompanyId);}src/CRM/AppointMe.Crm/Database/CrmDbContext.cs public DbSet<Customer> Customers => Set<Customer>();public DbSet<CustomerNote> CustomerNotes => Set<CustomerNote>();// …modelBuilder.Entity<Customer>().HasQueryFilter(CustomerFilters.SoftDelete, customer => !customer.IsDeleted).HasQueryFilter(CustomerFilters.CompanyId, customer => customer.CompanyId == currentCompany.CompanyId);modelBuilder.Entity<CustomerNote>().HasQueryFilter(CustomerNoteFilters.CompanyId, note => note.CompanyId == currentCompany.CompanyId);Add
using AppointMe.Crm.CustomerNotes;andusing AppointMe.Crm.CustomerNotes.Database;at the top. Every EF query onCustomerNotesnow sees only the current company’s rows. Multi-tenancy has the full checklist. -
Add the migration. From the repo root, run
dotnet efagainst the CRM project, with the API as the startup project:Terminal window dotnet ef migrations add AddCustomerNotes --project src/CRM/AppointMe.Crm --startup-project src/AppointMe.Api --context CrmDbContextEF writes the migration next to the existing ones in
Database/Migrations/and updatesCrmDbContextModelSnapshot.cs:src/CRM/AppointMe.Crm/Database/Migrations/<timestamp>_AddCustomerNotes.cs (generated) migrationBuilder.CreateTable(name: "CustomerNotes",schema: "crm",columns: table => new{Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),CompanyId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),CustomerId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),Text = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: false),CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false)},// …migrationBuilder.CreateIndex(name: "IX_CustomerNotes_CustomerId_CreatedAt",schema: "crm",table: "CustomerNotes",columns: new[] { "CustomerId", "CreatedAt" });The cut lines add the primary key and
FK_CustomerNotes_Customers_CustomerIdwithReferentialAction.Restrict. You don’t apply the migration by hand. The API runs pending migrations at startup and logsApplying migration '<timestamp>_AddCustomerNotes'.See Database migrations.
Write and read through the API
Section titled “Write and read through the API”A permission class, a command slice and a query slice. Wolverine injects the company and the caller into both handlers.
-
Add the permissions.
AddPermissions(ModuleAssembly.Instance), whichCrmModulealready calls, discovers any static class whose name ends inPermissions.src/CRM/AppointMe.Crm/CustomerNotes/CustomerNotePermissions.cs (new) using AppointMe.Shared.Authorization.Permissions;namespace AppointMe.Crm.CustomerNotes;public static class CustomerNotePermissions{private const string CustomerNotes = "customers.notes";public static readonly Permission View = new(CustomerNotes, "view");public static readonly Permission Create = new(CustomerNotes, "create");}No role holds a new permission until you grant it, so add it to the module’s default grant policy:
src/CRM/AppointMe.Crm/Authorization/CrmDefaultGrantPolicy.cs new(Role.Owner,CustomerPermissions.View,CustomerPermissions.Create,CustomerPermissions.Update,CustomerPermissions.Delete,CustomerPermissions.ViewStatistics,CustomerNotePermissions.View,CustomerNotePermissions.Create),// …new(Role.Staff,CustomerPermissions.View,CustomerNotePermissions.View),Role customers.notes:viewcustomers.notes:createOwner, Manager, Receptionist ✓ ✓ Staff ✓ — The two codes also appear in the OpenAPI
Permissionenum, which the frontend uses in step 10. See Add a permission. -
Add the command slice. Besides the factory from step 2,
AddCustomerNote/holds three message files, the handler and the endpoint:File Contents AddCustomerNoteCommand.cspublic sealed record AddCustomerNoteCommand(Guid CustomerId, string Text);AddCustomerNoteRequest.csA sealed classwithpublic required string Text { get; init; }AddCustomerNoteResponse.cspublic sealed record AddCustomerNoteResponse(Guid Id);The handler checks the permission first, then loads the customer through the filtered
DbSet:src/CRM/AppointMe.Crm/CustomerNotes/AddCustomerNote/AddCustomerNoteCommandHandler.cs (new) // …public sealed class AddCustomerNoteCommandHandler(CrmDbContext dbContext, TimeProvider timeProvider){public async Task<AddCustomerNoteResponse> HandleAsync(AddCustomerNoteCommand command, CompanyId companyId,IPrincipal principal, CancellationToken cancellationToken){principal.Require(CustomerNotePermissions.Create);var customer = await dbContext.Customers.LoadAsync(new CustomerId(command.CustomerId), cancellationToken);var note = CustomerNote.Add(companyId: companyId,customerId: customer.Id,text: LongString.Create(command.Text),createdAt: timeProvider.GetUtcNow());await dbContext.CustomerNotes.AddAsync(note, cancellationToken);await dbContext.SaveChangesAsync(cancellationToken);return new AddCustomerNoteResponse(note.Id.Value);}}Wolverine middleware fills in
CompanyIdandIPrincipal, and loading the principal is what checks that the caller works for that company.LoadAsync, fromCustomers/Database/CustomerQueries.cs, throwsNotFoundException(a404) when the customer doesn’t exist in the current company. The file also needsusinglines forAppointMe.Crm.Customers,AppointMe.Crm.Customers.DatabaseandAppointMe.Crm.Database.The endpoint maps the route under
api/v1and sends the command through Wolverine:src/CRM/AppointMe.Crm/CustomerNotes/AddCustomerNote/AddCustomerNoteEndpoint.cs (new) using Microsoft.AspNetCore.Http.HttpResults;using Microsoft.AspNetCore.Mvc;namespace AppointMe.Crm.CustomerNotes.AddCustomerNote;internal sealed class AddCustomerNoteEndpoint : IEndpoint{public void MapEndpoint(IEndpointRouteBuilder builder){builder.MapPost("/customers/{id:guid}/notes", AddCustomerNote).WithName(nameof(AddCustomerNote));}private static async Task<Created<AddCustomerNoteResponse>> AddCustomerNote(Guid id,[FromBody] AddCustomerNoteRequest request, IMessageBus bus, CancellationToken cancellationToken){var result = await bus.InvokeAsync<AddCustomerNoteResponse>(new AddCustomerNoteCommand(id, request.Text), cancellationToken);return TypedResults.Created($"/customers/{id}/notes", result);}}The class is
internaland needs no registration, becauseAddEndpoints(ModuleAssembly.Instance)finds everyIEndpointin the module. Add an endpoint covers the conventions. -
Write the Dapper read. Reads skip EF, so the repository takes a
CompanyIdand puts it in theWHEREclause itself.src/CRM/AppointMe.Crm/CustomerNotes/Database/CustomerNotesRepository.cs (new) // …public sealed class CustomerNotesRepository(IDbConnectionFactory connectionFactory){public async Task<IReadOnlyList<CustomerNoteDto>> GetByCustomer(CustomerId customerId, CompanyId companyId,CancellationToken cancellationToken){using var connection = await connectionFactory.OpenConnectionAsync(cancellationToken);var commandDefinition = new CommandDefinition("""SELECT [Id], [Text], [CreatedAt]FROM [crm].[CustomerNotes]WHERE [CompanyId] = @CompanyId AND [CustomerId] = @CustomerIdORDER BY [CreatedAt] DESC""",new { CompanyId = companyId.Value, CustomerId = customerId.Value },cancellationToken: cancellationToken);var notes = await connection.QueryAsync<CustomerNoteDto>(commandDefinition);return notes.ToList();}}The cut lines are the
usinglines forAppointMe.Crm.Customers,AppointMe.Shared.DatabaseandDapper, and the namespace. Register the repository next toCustomersRepository:src/CRM/AppointMe.Crm/Configuration/CrmModule.cs .AddScoped<CustomersRepository>().AddScoped<CustomerNotesRepository>() -
Add the query slice.
GetCustomerNotes/holds the query, the response, the handler and the endpoint:File Contents GetCustomerNotesQuery.cspublic sealed record GetCustomerNotesQuery(Guid CustomerId);GetCustomerNotesResponse.csA sealed classwithpublic required IReadOnlyList<CustomerNoteDto> Notes { get; init; }CustomerNoteDto.cs, inCustomerNotes/Id,TextandCreatedAtasrequiredproperties. Dapper maps the columns onto them by name.src/CRM/AppointMe.Crm/CustomerNotes/GetCustomerNotes/GetCustomerNotesQueryHandler.cs (new) // …public sealed class GetCustomerNotesQueryHandler(CustomerNotesRepository repository){public async Task<GetCustomerNotesResponse> HandleAsync(GetCustomerNotesQuery query, CompanyId companyId,IPrincipal principal, CancellationToken cancellationToken){principal.Require(CustomerNotePermissions.View);var notes = await repository.GetByCustomer(new CustomerId(query.CustomerId), companyId, cancellationToken);return new GetCustomerNotesResponse{Notes = notes};}}src/CRM/AppointMe.Crm/CustomerNotes/GetCustomerNotes/GetCustomerNotesEndpoint.cs (new) namespace AppointMe.Crm.CustomerNotes.GetCustomerNotes;internal sealed class GetCustomerNotesEndpoint : IEndpoint{public void MapEndpoint(IEndpointRouteBuilder builder){builder.MapGet("/customers/{id:guid}/notes", GetCustomerNotes).WithName(nameof(GetCustomerNotes));}private static async Task<GetCustomerNotesResponse> GetCustomerNotes(Guid id, IMessageBus bus,CancellationToken cancellationToken){return await bus.InvokeAsync<GetCustomerNotesResponse>(new GetCustomerNotesQuery(id), cancellationToken);}}Check: run
dotnet build AppointMe.sln. The build also writessrc/AppointMe.Api/obj/openapi.json, which now lists both operations under/api/v1/customers/{id}/notes.
Show it in the React app
Section titled “Show it in the React app”Regenerate the typed client, label the new permissions, then render the notes with the generated TanStack Query hooks.
-
Regenerate the client. You don’t need a running backend: the script builds the API, which writes the OpenAPI document, then runs orval against it.
Terminal window cd src/AppointMe.Frontendnpm run generate:apiGenerated in src/api/Use useGetCustomerNotes(id)Reads a GetCustomerNotesResponsegetGetCustomerNotesQueryKey(id)The cache key to invalidate after a write useAddCustomerNote()A mutation whose variables are { id: string; data: AddCustomerNoteRequest }PermissionGains 'customers.notes:view'and'customers.notes:create'The hook names come from
.WithName(nameof(...))on each endpoint. See Typed API client. -
Label the new permissions.
npm run checknow fails, because the labels are aRecord<Permission, string>and two keys are missing. The error ends inmissing the following properties from type 'Record<Permission, string>': "customers.notes:create", "customers.notes:view". Add both, plus a label for the new group on the Permissions settings page:src/AppointMe.Frontend/src/app/settings/permissions/permission-labels.ts 'customers.statistics': 'Customer statistics','customers.notes': 'Customer notes',};// …'customers.statistics:view': 'View customer statistics','customers.notes:view': 'View customer notes','customers.notes:create': 'Add customer notes', -
Render the notes. Add two components to
app/customers/: a form that posts a note and invalidates the list, and the list itself. Each is gated by its permission with<Can>.src/AppointMe.Frontend/src/app/customers/add-customer-note-form.tsx (new) // …export const AddCustomerNoteForm = ({ customerId }: { customerId: string }) => {const queryClient = useQueryClient();const [text, setText] = useState('');const { mutateAsync: addNote, isPending } = useAddCustomerNote({mutation: {onSuccess: () => queryClient.invalidateQueries({ queryKey: getGetCustomerNotesQueryKey(customerId) }),},});return (<formonSubmit={async event => {event.preventDefault();await addNote({ id: customerId, data: { text } });setText('');}}><Textarea value={text} maxLength={4000} onChange={event => setText(event.target.value)} /><Button type='submit' size='sm' disabled={isPending || !text.trim()}>Add note</Button></form>);};src/AppointMe.Frontend/src/app/customers/customer-notes.tsx (new) import { AddCustomerNoteForm } from './add-customer-note-form';import { useGetCustomerNotes } from '@/api/client';import { Can } from '@/components/auth/can';import { FormattedDate } from '@/components/format';export const CustomerNotes = ({ customerId }: { customerId: string }) => {const { data } = useGetCustomerNotes(customerId);return (<section className='space-y-3'><h2 className='text-lg font-semibold'>Notes</h2><Can permission='customers.notes:create'><AddCustomerNoteForm customerId={customerId} /></Can><ul className='space-y-2'>{data?.notes.map(note => (<li key={note.id} className='rounded-md border p-3 text-sm'><p className='whitespace-pre-wrap'>{note.text}</p><FormattedDate date={note.createdAt} format='dayMonthShortYearTime24' /></li>))}</ul></section>);};Then mount it under the details on the profile page, and import it with
import { CustomerNotes } from './customer-notes';:src/AppointMe.Frontend/src/app/customers/customer-profile.tsx </dl><Can permission='customers.notes:view'><CustomerNotes customerId={customer.id} /></Can>The form’s cut lines import
getGetCustomerNotesQueryKeyanduseAddCustomerNotefrom@/api/client,ButtonandTextareafrom@/components/ui,useQueryClientanduseState. The app’s dialogs use react-hook-form and zod; this form keeps the hook-up minimal. Check:npm run checkpasses the format check, lint and type-check.
Verify it works
Section titled “Verify it works”The CRM tests cover the factory and the permission check. The running app and the OpenAPI document cover the rest.
Add a test file next to the existing CRM tests. It uses the same pattern as GetNewCustomerStatsQueryHandlerTests: a principal with no permissions, and a handler that must fail before it touches the database.
[Fact] public async Task should_deny_when_principal_lacks_create_permission() { var handler = new AddCustomerNoteCommandHandler(null!, TimeProvider.System); var principal = new UserPrincipal(new CompanyId(NewId()), new HashSet<Role>(), new HashSet<Permission>());
await Assert.ThrowsAsync<AccessDeniedException>(() => handler.HandleAsync( new AddCustomerNoteCommand(NewId(), "Prefers mornings"), principal.CompanyId, principal, CancellationToken.None)); }The file’s other test, should_add_note_to_customer, builds a customer with Create.Customer.Build() and asserts that CustomerNote.Add copies every field. Run the module’s tests:
dotnet test src/CRM/AppointMe.Crm.TestsExpected: 9 tests pass, the module’s 7 plus your 2. Then try the feature:
| Where | Do | Expect |
|---|---|---|
| The app | Start the stack, open Customers, click a row | A Notes section under the customer’s details. A note you add appears at the top. |
| OpenAPI | Open https://localhost:7233/openapi/v1.json (Development only) |
GetCustomerNotes and AddCustomerNote under /api/v1/customers/{id}/notes |
| HTTP | POST /api/v1/customers/{id}/notes with {"text":"Prefers mornings"} |
201 and {"id":"…"}. A GET on the same path returns {"notes":[…]}. |
Every HTTP call needs a signed-in user and the X-Company-Id header. A cookie session also needs the X-XSRF-TOKEN header on a POST. The SPA’s axios client sends both, so the browser’s network tab is the quickest place to watch the calls.
Troubleshooting
Section titled “Troubleshooting”Most failures are a missing grant, a missing label or a stale client.
| Symptom | Cause | Fix |
|---|---|---|
npm run check: missing the following properties from type 'Record<Permission, string>' |
New permission codes have no labels | Add them to permission-labels.ts (step 10) |
403 with The following permissions are required: customers.notes:create. |
The caller’s role isn’t granted the permission | Add it to CrmDefaultGrantPolicy, or grant it on the Permissions settings page |
404 with Customer with id='…' not found |
The customer belongs to another company, or is deleted | Send the X-Company-Id of the customer’s company |
400 with "error": "Value cannot be empty." |
The note text is blank, so LongString.Create rejects it |
Send non-blank text |
useGetCustomerNotes isn’t in client.ts |
You ran npx orval alone, so it read an old obj/openapi.json |
Run npm run generate:api, which builds first |
dotnet ef migrations remove fails with a SQL network error |
remove connects to the database to check whether the migration was applied |
Start SQL Server first. See Database migrations. |
Why a separate aggregate instead of a list of notes on Customer?1 min · optional
A note has its own life. It’s written far more often than the customer changes, and listing notes never needs the customer’s fields. As a separate aggregate, a note is one INSERT, and the list is one Dapper query that sorts on its own index.
It also leaves Customer alone. The aggregate, its events and the contracts in AppointMe.Crm.Contracts that Booking consumes don’t change.
Why load the customer when there's a foreign key?1 min · optional
The foreign key proves the customer exists. It can’t prove the customer belongs to the caller’s company, because the database doesn’t know who is calling.
dbContext.Customers.LoadAsync runs through the CompanyId query filter, so a customer ID from another tenant fails with 404 before anything is written. The note’s own CompanyId is the injected one, the same value the filter used.