Layer types — Controllers, Managers, Providers, Services
Projects and namespaces
Most apps are a single project (Company.Product); split further only when a second app reuses code or size demands it — see One project, or several. When a solution does split, the projects and their roles are:
| Project | Role | What lives here |
|---|---|---|
Company.Product.Web |
The application | Controllers, Managers, Providers, Services, Models, Mappers, Helpers, Options, Middlewares, Jobs |
Company.Product.Database |
Generated provider infrastructure | Generated/DbContext.cs, Generated/Models/*, Generated/Processors/* — all autogenerated |
Company.Product.Common |
Cross-cutting, framework-free utilities | Extensions/*, shared value types that carry no ASP.NET dependency |
Namespaces mirror the folder path exactly: a file in TwinPeaks.DocumentHub.Web/Managers/ is in namespace TwinPeaks.DocumentHub.Web.Managers. Under a feature-first layout the same rule applies — Features/Documents/DocumentsManager.cs is in TwinPeaks.DocumentHub.Web.Features.Documents. Don't invent a namespace that doesn't match the folder, and don't put two unrelated top-level types in one file.
Controllers — <Subject>Controller
The I/O layer. One controller per subject, plural, matching the general singular/plural rule and the endpoint:
| Endpoint | Controller | Manager it calls |
|---|---|---|
/api/documents |
DocumentsController |
DocumentsManager |
/api/users |
UserController / UsersController |
UsersManager |
/api/email-templates |
EmailTemplatesController |
EmailTemplateManager |
/api/me |
MeController |
UsersManager |
Rules:
[Route("api/[controller]")]+[ApiController]is the default. The[controller]token resolves to the class name minusController, soDocumentsController→/api/documents. Use an explicit route string ([Route("api/message-logs")]) only when the token doesn't produce the URL you want.- Inherit the common base (
CommonController) that providesGetUserContextAsync, the cache, and the shared constructor dependencies via: base(...). - Coarse authorization on the class (
[Authorize]), fine-grained permission on the action ([PermissionsAuthorize("documents.create_document", …)],[AllowAnonymous]). - Action names are verb-first with the
Asyncsuffix and read as the operation:GetDocumentsAsync,UploadDocumentFileAsync,SetPreferredLocaleAsync. HTTP method comes from the attribute ([HttpGet],[HttpPost("documents")],[HttpPatch("{documentCode}/file/{id:guid}")]), not from the method name. - Bind explicitly:
[FromBody],[FromRoute],[FromQuery],[FromForm].CancellationTokenis the last parameter, unbound. - Return
Task<ResponseModel<T>>for JSON payloads, orTask<IActionResult>for file downloads / redirects / raw status codes. The action body stays under ~25 lines — see Controllers stay thin.
Managers — <Subject>Manager
The Management layer. One manager per business subject, plural:
| Manager | Interface | Subject |
|---|---|---|
DocumentsManager |
IDocumentsManager |
Documents |
UsersManager |
IUsersManager |
Users |
TenantManager |
ITenantManager |
Tenants |
EmailTemplateManager |
(none — no test double needed) | Email templates |
- Give a manager an interface when it has a seam worth faking — a controller test that stubs it, or an alternative implementation. A specialized manager with a single caller and no test double (
EmailTemplateManager,SendoutImportManager) can stay concrete until that changes. This is use only what you need applied to interfaces. - A manager may call other managers (
AuthManagerdepends onIUsersManager) — orchestration across subjects is exactly its job. It must not reach into a provider's internals or bypass a provider to hitDbContext. - Split a large manager by sub-subject, never by verb.
Documentsgrowing aVersionsconcern becomes aDocumentVersionsManager, not aDocumentsReadManager/DocumentsWriteManagerpair.
Providers — <Subject>Provider
The Providers layer. Atomic: one database subject or one external system each.
| Provider | Interface | Talks to |
|---|---|---|
DocumentProvider |
IDocumentProvider |
the document DB subject |
UsersProvider |
IUsersProvider |
the user DB subject |
EmailProvider |
IEmailProvider |
SMTP |
TwilioSmsProvider |
ISmsProvider |
Twilio |
ActiveDirectoryProvider |
IActiveDirectoryProvider |
LDAP / AD |
GraphApiProvider |
IActiveDirectoryProvider |
Microsoft Graph |
- Database providers are singular-subject (
DocumentProvider, notDocumentsProvider) when they wrap operations on that one entity's stored functions. Follow the surrounding codebase's existing choice for consistency; the rule that matters is one subject per provider. - External-system providers implement a capability interface so implementations are swappable:
TwilioSmsProvider,HttpSmsProvider,MockSmsProviderall implementISmsProvider. Group a family in its own folder (Providers/SmsProviders/). - A provider never calls another provider. If an operation needs two providers, the manager coordinates them.
Services — <Capability>Service
Side-layer DI'd utilities that are neither a single external system nor cross-subject orchestration: EmailHtmlService, EncryptionService, ContextService. Interface I<Capability>Service when it's worth faking. Naming is capability-first, not subject-plural — a service is "the thing that renders email HTML", so EmailHtmlService, not EmailsService. See the Provider vs Service vs Manager table.
Interfaces — I<Type>
- Prefix every interface with
I:IDocumentsManager,IEmailProvider,IContextService. - Create an interface for a manager/provider/service when there is a real seam — a second implementation, or a test that needs a double. Don't reflexively pair every class with a one-implementation interface; that is ceremony, not abstraction.
- Name the interface after the thing, not the pattern:
IEmailProvider, notIEmailProviderServiceorIProvidesEmail.