Skip to content
StarLive demo →
TutorialChecked against main · 31 Oct 202613 min read

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

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

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.

  1. Add the ID and the aggregate. Create CustomerNotes/ next to Customers/. The ID wraps a Guid the way CustomerId does, 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; }
    }

    LongString is the shared value object for free text up to LongString.MaxLength (4,000) characters. AggregateRoot, CompanyId and LongString need no using, because the module’s GlobalUsings.cs imports their namespaces.

  2. Write the factory. Domain operations are extension methods in the slice that owns them, so CustomerNote.Add lives in AddCustomerNote/, the way Customer.Register lives in RegisterCustomer/.

    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 a string, so it can’t receive unvalidated text. The handler in step 6 builds it with LongString.Create, which throws ValidationException for blank text or text over 4,000 characters. NewId() returns a version 7 Guid.

  3. Map it in EF Core and scope it to the tenant. Add a type configuration in CustomerNotes/Database/. The ApplyConfigurationsFromAssembly call in CrmDbContext picks 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 CustomerTypeConfiguration does, for example .HasConversion(id => id.Value, value => new CustomerNoteId(value)). The index starts with CustomerId, 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; and using AppointMe.Crm.CustomerNotes.Database; at the top. Every EF query on CustomerNotes now sees only the current company’s rows. Multi-tenancy has the full checklist.

  4. Add the migration. From the repo root, run dotnet ef against 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 CrmDbContext

    EF writes the migration next to the existing ones in Database/Migrations/ and updates CrmDbContextModelSnapshot.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_CustomerId with ReferentialAction.Restrict. You don’t apply the migration by hand. The API runs pending migrations at startup and logs Applying migration '<timestamp>_AddCustomerNotes'. See Database migrations.

A permission class, a command slice and a query slice. Wolverine injects the company and the caller into both handlers.

  1. Add the permissions. AddPermissions(ModuleAssembly.Instance), which CrmModule already calls, discovers any static class whose name ends in Permissions.

    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:view customers.notes:create
    Owner, Manager, Receptionist ✓ ✓
    Staff ✓ —

    The two codes also appear in the OpenAPI Permission enum, which the frontend uses in step 10. See Add a permission.

  2. Add the command slice. Besides the factory from step 2, AddCustomerNote/ holds three message files, the handler and the endpoint:

    File Contents
    AddCustomerNoteCommand.cs public sealed record AddCustomerNoteCommand(Guid CustomerId, string Text);
    AddCustomerNoteRequest.cs A sealed class with public required string Text { get; init; }
    AddCustomerNoteResponse.cs public 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 CompanyId and IPrincipal, and loading the principal is what checks that the caller works for that company. LoadAsync, from Customers/Database/CustomerQueries.cs, throws NotFoundException (a 404) when the customer doesn’t exist in the current company. The file also needs using lines for AppointMe.Crm.Customers, AppointMe.Crm.Customers.Database and AppointMe.Crm.Database.

    The endpoint maps the route under api/v1 and 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 internal and needs no registration, because AddEndpoints(ModuleAssembly.Instance) finds every IEndpoint in the module. Add an endpoint covers the conventions.

  3. Write the Dapper read. Reads skip EF, so the repository takes a CompanyId and puts it in the WHERE clause 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] = @CustomerId
    ORDER 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 using lines for AppointMe.Crm.Customers, AppointMe.Shared.Database and Dapper, and the namespace. Register the repository next to CustomersRepository:

    src/CRM/AppointMe.Crm/Configuration/CrmModule.cs
    .AddScoped<CustomersRepository>()
    .AddScoped<CustomerNotesRepository>()
  4. Add the query slice. GetCustomerNotes/ holds the query, the response, the handler and the endpoint:

    File Contents
    GetCustomerNotesQuery.cs public sealed record GetCustomerNotesQuery(Guid CustomerId);
    GetCustomerNotesResponse.cs A sealed class with public required IReadOnlyList<CustomerNoteDto> Notes { get; init; }
    CustomerNoteDto.cs, in CustomerNotes/ Id, Text and CreatedAt as required properties. 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 writes src/AppointMe.Api/obj/openapi.json, which now lists both operations under /api/v1/customers/{id}/notes.

Regenerate the typed client, label the new permissions, then render the notes with the generated TanStack Query hooks.

  1. 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.Frontend
    npm run generate:api
    Generated in src/api/ Use
    useGetCustomerNotes(id) Reads a GetCustomerNotesResponse
    getGetCustomerNotesQueryKey(id) The cache key to invalidate after a write
    useAddCustomerNote() A mutation whose variables are { id: string; data: AddCustomerNoteRequest }
    Permission Gains 'customers.notes:view' and 'customers.notes:create'

    The hook names come from .WithName(nameof(...)) on each endpoint. See Typed API client.

  2. Label the new permissions. npm run check now fails, because the labels are a Record<Permission, string> and two keys are missing. The error ends in missing 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',
  3. 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 (
    <form
    onSubmit={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 getGetCustomerNotesQueryKey and useAddCustomerNote from @/api/client, Button and Textarea from @/components/ui, useQueryClient and useState. The app’s dialogs use react-hook-form and zod; this form keeps the hook-up minimal. Check: npm run check passes the format check, lint and type-check.

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.

src/CRM/AppointMe.Crm.Tests/CustomerNotes/AddCustomerNote/AddCustomerNoteTests.cs (new)
[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:

Terminal window
dotnet test src/CRM/AppointMe.Crm.Tests

Expected: 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.

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.