Skip to content

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 minus Controller, so DocumentsController/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 provides GetUserContextAsync, 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 Async suffix 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]. CancellationToken is the last parameter, unbound.
  • Return Task<ResponseModel<T>> for JSON payloads, or Task<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 (AuthManager depends on IUsersManager) — orchestration across subjects is exactly its job. It must not reach into a provider's internals or bypass a provider to hit DbContext.
  • Split a large manager by sub-subject, never by verb. Documents growing a Versions concern becomes a DocumentVersionsManager, not a DocumentsReadManager / DocumentsWriteManager pair.

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, not DocumentsProvider) 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, MockSmsProvider all implement ISmsProvider. 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, not IEmailProviderService or IProvidesEmail.