# 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.

Source page: https://appointme.dev/docs/guides/remove-a-module/

* **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

## Who depends on whom

**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`.

### What you can’t remove

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](/docs/deploy/entra-external-id/). The modules themselves are on [Identity](/docs/modules/identity/) and [Organizations](/docs/modules/organizations/).

## Revert the database first

**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:

   ```bash
   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:

   ```bash
   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:

   ```sql
   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.cs

   ```cs
       public Permission Get(string code) => _permissionsByCode.TryGetValue(code, out var permission)
           ? permission
           : throw new InvalidOperationException($"Unknown permission '{code}'");
   ```

Gotcha

Skip the `DELETE` and, in each company that customized an `appointments` permission, requests that need a principal fail with `500` for every user who holds an overridden role. A unit test against `PermissionResolver` with an override for `appointments:view` and no such permission registered reproduces it: `Unknown permission 'appointments:view'`.

## Remove the backend

**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.

   ```bash
   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.cs

   ```cs
   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.cs

   ```cs
       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

   ```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`.

   ```bash
   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.

## Remove the frontend

**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`:

   ```bash
   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:

   ```bash
   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.tsx

   ```tsx
                                       {
                                           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:

   ```bash
   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.

## After you deploy

**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.

## Verify it works

**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`                                                                           |

## Troubleshooting

**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?

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.

## Next

* [Add a module](/docs/guides/add-a-module/) (Guide)
* [Modular monolith](/docs/concepts/modular-monolith/) (Concept)
* [Booking](/docs/modules/booking/) (Module)
