Skip to content
StarLive demo →
GuideChecked against main · 31 Oct 20269 min read

Remove a module

Remove a module from the AppointMe .NET 10 modular monolith. A dependency map, then the exact steps that took Booking out with a green build and tests, from projects to SPA routes, migrations and Hangfire jobs.

You'll remove
Booking, the least-coupled module
Time
About an hour
Touches
Solutions, API host, SPA, database, Hangfire
Can't remove
Identity and Organizations

Booking is a leaf: no other module references it. CRM is next, because only Booking uses its contracts. Identity and Organizations hold up everything else.

This map comes from every <ProjectReference> and every cross-module using at the documented commit.

Module Its projects reference Used by other modules Used by the API host
Shared Nothing All of them All of it
Organizations Shared Identity handles EmployeeInvited and EmployeeInvitationResent. CRM’s demo top-up job reads ICompanyRehydrationSource. Booking projects companies and employees. CurrentPrincipalResolver and UserPrincipalFactory build every principal. CompanyRegistered starts DemoSeedingSaga.
Identity Shared, Organizations.Contracts None HttpIdentityFactory turns every sign-in into a UserIdentity through IUserIdentityRegistry
CRM Shared, Organizations.Contracts Booking handles the customer events and reads ICustomerRehydrationSource DemoSeedingSaga sends SeedDemoCustomersCommand
Booking Shared, CRM.Contracts, Organizations.Contracts None DemoSeedingSaga waits for ServiceProviderProjected and AttendeeProjected, then sends SeedDemoAppointmentsCommand

To remove CRM, remove Booking first, or rewrite Booking without attendees. Every step below was run on a fresh checkout. The result built with 0 Error(s), all 187 remaining tests passed, Wolverine’s codegen test passed, and the SPA passed npm run check and npm run build.

Identity and Organizations are the platform the other modules run on. Remove either one and no request gets past authentication or a permission check.

Module What depends on it What breaks without it
Identity HttpIdentityFactory in the API host, the /login, /logout and /signup endpoints, and EmployeeInvitedHandler, which creates invited users in Keycloak or Entra The host’s HttpIdentityFactory stops compiling. Without a replacement, nothing turns a sign-in into a UserIdentity, so no caller gets past anonymous.
Organizations AddAppointMeAuthorization() registers its CurrentPrincipalResolver. CRM, Booking and Identity reference its contracts. No principal, no companies to be a tenant of, no GET /api/v1/me or /me/access, no onboarding and no permissions screen

Both are also referenced from inside the host, not only from Program.cs. To swap the identity provider instead, see Entra External ID. The modules themselves are on Identity and Organizations.

Undo the module’s migrations while its project still exists, because dotnet ef needs the migration classes to know what to drop.

  1. Drop the module’s tables. Locally, revert every Booking migration against the Aspire database:

    Terminal window
    dotnet ef database update 0 --project src/Booking/AppointMe.Booking --startup-project src/AppointMe.Api --context BookingDbContext --connection "Server=localhost,60740;Database=AppointMe;User ID=sa;Password=Password1;TrustServerCertificate=True"

    For a shared or production database, write the same steps to a script and run it through your usual change process. 20260813125851_AddDashboardStatsIndexes is Booking’s last migration:

    Terminal window
    dotnet ef migrations script 20260813125851_AddDashboardStatsIndexes 0 --project src/Booking/AppointMe.Booking --startup-project src/AppointMe.Api --context BookingDbContext -o drop-booking.sql

    Either way, Appointments, Attendees, ServiceProviders and BookingCompanies are dropped, and the booking schema and an empty history table stay behind.

  2. Drop what EF leaves. Then remove the stored overrides for the module’s permissions:

    DROP TABLE [booking].[__EFMigrationsHistory];
    DROP SCHEMA [booking];
    DELETE FROM [organizations].[RolePermissionOverrides] WHERE [PermissionCode] LIKE 'appointments%';

    The DELETE isn’t optional. When a user’s role has an override for a code that no module declares any more, building their principal throws:

    src/AppointMe.Shared/Authorization/Permissions/PermissionRegistry.csView source ↗
    public Permission Get(string code) => _permissionsByCode.TryGetValue(code, out var permission)
    ? permission
    : throw new InvalidOperationException($"Unknown permission '{code}'");

Delete the three projects, take them out of both solution files and the host, and replace the one host class that orchestrates Booking.

  1. Remove the projects from both solutions. dotnet sln also drops the empty Booking solution folder.

    Terminal window
    dotnet sln AppointMe.sln remove src/Booking/AppointMe.Booking.Contracts/AppointMe.Booking.Contracts.csproj src/Booking/AppointMe.Booking/AppointMe.Booking.csproj src/Booking/AppointMe.Booking.Tests/AppointMe.Booking.Tests.csproj
    dotnet sln AppointMe.slnx remove src/Booking/AppointMe.Booking.Contracts/AppointMe.Booking.Contracts.csproj src/Booking/AppointMe.Booking/AppointMe.Booking.csproj src/Booking/AppointMe.Booking.Tests/AppointMe.Booking.Tests.csproj
    git rm -r src/Booking

    The migrations live inside the module project, so they go with it.

  2. Unreference it from the API. Delete the AppointMe.Booking.csproj line from src/AppointMe.Api/AppointMe.Api.csproj. The API references only the module project, not its contracts.

  3. Unregister it in Program.cs. Delete using AppointMe.Booking.Configuration; and the last call in this chain, and move the semicolon up:

    src/AppointMe.Api/Program.csView source ↗
    builder.Services
    .AddSharedModule(builder.Configuration)
    .AddIdentityModule(builder.Configuration)
    .AddOrganizationsModule()
    .AddCrmModule()
    .AddBookingModule();
  4. Replace DemoSeedingSaga. It’s the only other host code that breaks, with 7 compile errors. The saga seeds customers only after Booking projects the company’s first service provider, and seeds appointments as attendees appear:

    src/AppointMe.Api/Demo/DemoSeedingSaga.csView source ↗
    public object? Handle(
    [SagaIdentityFrom(nameof(ServiceProviderProjected.CompanyId))]
    ServiceProviderProjected @event)
    {
    if (CustomerSeedingTriggered)
    {
    return null;
    }
    CustomerSeedingTriggered = true;
    return new SeedDemoCustomersCommand(CustomersExpected)
    .WithCompany(@event.CompanyId);
    }

    Without Booking, nothing sends ServiceProviderProjected, so demo companies would get no customers. Delete the saga and seed customers straight from CompanyRegistered:

    src/AppointMe.Api/Demo/DemoSeedingHandler.cs
    using AppointMe.Crm.Customers.SeedDemoCustomers;
    using AppointMe.Organizations.Contracts.Companies.Events;
    namespace AppointMe.Api.Demo;
    public static class DemoSeedingHandler
    {
    private const int CustomerCount = 150;
    public static object? Handle(CompanyRegistered @event, IConfiguration configuration)
    {
    if (!configuration.GetValue<bool>("Demo:Enabled"))
    {
    return null;
    }
    return new SeedDemoCustomersCommand(CustomerCount).WithCompany(@event.CompanyId);
    }
    }

    WithCompany puts the company on the cascaded command, so SeedDemoCustomersCommandHandler gets its CompanyId.

  5. Build, test and check the handlers. codegen preview should list DemoSeedingHandler and nothing from AppointMe.Booking.

    Terminal window
    dotnet build AppointMe.sln
    dotnet test AppointMe.sln
    dotnet build AppointMe.sln -c Release
    dotnet run --project src/AppointMe.Api/AppointMe.Api.csproj -c Release --no-build --no-launch-profile -- codegen test

Nothing else in the backend changes. Aspire, compose.yaml and infra/ have nothing per module, and CRM still publishes its customer events: with no handler left, nothing consumes them. README.md and CLAUDE.md still describe Booking, so edit them to match.

Regenerate the client first. The type checker then lists every file that used the module, and you delete or edit each one.

  1. Regenerate and check. From src/AppointMe.Frontend:

    Terminal window
    npm run generate:api
    npm run check

    check fails with TS2305 (has no exported member 'AppointmentDto', 'useGetAppointments', 'TrendBucketDto', …) and TS2322 (Type '"appointments:schedule"' is not assignable to type 'Permission[] | Permission') in the files below.

  2. Delete the module’s pages. src/app/appointments/ is all Booking. The dashboard in src/app/dashboard/ is built on Booking’s statistics endpoints, so it goes too:

    Terminal window
    git rm -r src/app/appointments src/app/dashboard

    The dashboard’s new-customers chart reads CRM. Deleting the whole dashboard is what was verified here. To keep that chart, rebuild it on useGetNewCustomerStats alone.

  3. Fix the router. Remove the two imports and the two routes, and point the index route somewhere that still exists:

    src/AppointMe.Frontend/src/app/router/router.tsxView source ↗
    {
    index: true,
    element: <Navigate to='/appointments' replace />,
    },
    {
    path: 'dashboard',
    element: <Dashboard />,
    handle: { breadcrumb: 'Dashboard', navId: 'dashboard' },
    },
    {
    path: 'appointments',
    element: <Appointments />,
    handle: { breadcrumb: 'Appointments', navId: 'appointments' },
    },

    This run sent the index route to /customers.

  4. Fix the sidebar and the labels. In src/app/layouts/sidebar/nav-data.ts, delete the Appointments and Dashboard entries and the unused CalendarIcon and ChartPieIcon imports. In src/app/settings/permissions/permission-labels.ts, delete the two appointments resource labels and the five appointments permission labels.

  5. Check again. Both pass after the edits above:

    Terminal window
    npm run check
    npm run build

    The role descriptions in src/app/team/roles.ts still mention appointments. They’re plain text, so edit them to suit what’s left.

Hangfire still holds Booking’s recurring jobs, and nothing in the code removes them. Delete them once the new version is running.

Job ID Schedule Registered when
booking:company-reconciliation Hourly, on the hour Always
booking:service-provider-reconciliation Hourly, on the hour Always
booking:attendee-reconciliation Daily, 03:00 UTC Always
booking:top-up-demo-appointments Daily, 03:00 UTC Demo:Enabled is true

When one comes due, the scheduler logs Recurring job 'booking:company-reconciliation' can't be scheduled due to an error and will be retried in 00:00:15. with Could not resolve assembly 'AppointMe.Booking', and tries again 15 seconds later. Hangfire 1.8 gives up after five retries: it logs … can't be scheduled due to an error and will be disabled. and stops scheduling the job, but the job stays in its storage. Sign in as a super admin, open the Hangfire dashboard at /admin/jobs, and delete them under Recurring Jobs. In code, IRecurringJobManager.RemoveIfExists("booking:company-reconciliation") does the same for one job.

The same checks CI runs, plus a database check.

Check Command Expect
Solution builds dotnet build AppointMe.sln 0 Error(s)
Tests pass dotnet test AppointMe.sln 5 test projects pass: Shared 118, Identity 9, Organizations 28, CRM 7, Api 25
Handlers wire codegen test, as above Success!
SPA is clean npm run check and npm run build No type errors, and ✓ built
No Booking left in the database SELECT name FROM sys.schemas WHERE name = 'booking' No rows
No stale overrides SELECT COUNT(*) FROM [organizations].[RolePermissionOverrides] WHERE [PermissionCode] LIKE 'appointments%' 0

Each symptom points at one step you skipped.

Symptom Cause Fix
CS0234: The type or namespace name 'Booking' does not exist in DemoSeedingSaga.cs The saga still uses Booking’s events and commands Replace it with the handler above
500 with Unknown permission 'appointments:view' in the log, for some users only Stored overrides still name Booking’s permissions Run the DELETE on RolePermissionOverrides
Recurring job 'booking:…' can't be scheduled due to an error in the log Hangfire still has Booking’s recurring jobs Delete them on /admin/jobs
dotnet ef database update 0 can’t find the project or BookingDbContext The project, or the host’s reference and AddBookingModule() call, is already deleted Run the revert from a checkout of the commit before the removal, then come back
booking tables are still there after a deploy Nothing drops a deleted module’s schema Run the revert script and the DROP statements
New demo companies get no customers DemoSeedingSaga was deleted and not replaced Add DemoSeedingHandler
Why is removing a module mostly deleting a folder?2 min · optional

Other modules reach a module only through its contracts project, and the host through its Add*Module() call and project reference. So once the projects are gone, the compiler shows every remaining use. For Booking there were two, both in the host: Program.cs and DemoSeedingSaga.

What the compiler can’t see is state outside the code: tables, permission overrides and Hangfire job definitions keep the module’s names after the code is gone. That’s why the database and Hangfire steps are the ones most worth reading twice.