Imported from Abdelrhman-elsaeed/English-Platform (
.agents/skills/dotnet-layered-api-architect/SKILL.md). Install upstream withnpx skills add Abdelrhman-elsaeed/English-Platform --skill dotnet-layered-api-architect. Copyright stays with the author.
.NET Layered API Architect
Your mission. Build (or extend) an ASP.NET Core Web API on a disciplined 3-tier architecture — Controllers → Services → Repositories → Database — using EF Core Code First. This isn't abstract theory; it's a playbook earned from real bugs on a real project (an Examination System), and every rule below exists because a specific shortcut caused a specific, costly problem. Follow the rules even when "it would still work" without them — the cost of skipping one shows up two features later, not on the line you skipped it on. The reason any of this matters: a codebase where layers and relationships are obvious is one a teammate — human, or another AI session with none of this context — can open cold and actually work in, instead of reading every file just to find where one piece of logic lives.
Be disciplined about layering; pragmatic about scope. These don't conflict:
- Disciplined on layering — a Controller never touches a Repository or
DbContext. A Repository never contains business logic. A Service never returnsIQueryable. Don't bend these "just this once." - Pragmatic on scope — don't add Authentication, Redis caching, Stored Procedures, or Polyglot Persistence unless asked or genuinely needed right now. They're documented below as the next step, not a mandatory starting point (§11).
Stay inside the task you were given. Spot a bug outside today's work, or a feature the spec implies but nobody asked for yet? Don't fix it or build it silently — note it in your report (§13) and move on. Quietly expanding scope hides a decision that belongs to the user, not you.
Restructure boldly; preserve behavior carefully. Once a target shape is clear — split that bloated controller, rename that unclear method, move that file — do it with confidence. Refusing to touch something "to be safe" when no rule above is actually at risk just leaves bad structure in place. The real caution belongs to behavior: when extending existing code, whatever worked before your change must still work after it. These are two different kinds of caution — don't let care about one turn into timidity about the other.
Never assume what the spec doesn't say. Ordinary properties (a name, an hours count) are safe to assume. Relationships between entities are not — if the spec doesn't state how two things relate, ask; don't guess a One-to-Many when the real shape is Many-to-Many (§4). When a structural call is genuinely ambiguous — bridge entity or direct navigation, one Service or two — work through the tradeoff explicitly rather than taking the first shape that compiles; the wrong call here is expensive three features later.
Work like the change is permanent. Read a file in full before editing it — don't infer the rest of it from a fragment. Commit after each meaningful step (a layer, an entity, a feature) with a message that says why, and don't hold back from using git diff/git log/a revert to inspect or undo precisely when something goes sideways, instead of unpicking it by hand. An extra pass to get a structural call right is cheaper than discovering it was wrong three files later — don't trade thoroughness for speed.
1. The target architecture
┌────────────────────────────────────────┐
│ Controllers (API layer) │ ← receives ViewModels, returns ResponseViewModel
├────────────────────────────────────────┤
│ Services (business layer) │ ← all business logic; works with DTOs
├────────────────────────────────────────┤
│ Repositories (data access layer) │ ← only EF/SQL knowledge; works with Entities
├────────────────────────────────────────┤
│ Database │
└────────────────────────────────────────┘
The dependency rules — never break these
- Controller → Service only. Never inject a Repository or
DbContextinto a Controller. - Service → its own Repository, and/or other Services. Inject a sibling Service, not its Repository — that's how shared logic ("get all questions for a course") gets written once and reused, instead of duplicated per consumer.
- Repository →
DbContextonly. No business rules, no validation, no cross-entity decisions inside a repository.
Breaking rule 1 or 3 causes the same disease either way: duplicated queries across controllers, a hard dependency on one data source, and a Controller that ends up knowing SQL. (This is SOLID's Single Responsibility and Dependency Inversion principles applied literally, not as interview trivia — each layer has one reason to change, and depends on an abstraction of the layer below it, never a concrete detail.)
Transport adapters — when REST isn't the only door in
A Controller is really just the HTTP-flavored adapter for the Service layer underneath it — "Controller" and "transport" aren't actually synonyms. The moment a project talks to the outside world through more than one protocol — a WebSocket hub for real-time updates, a gRPC endpoint, a message-queue consumer — that's a second transport, and it needs its own thin adapter class, never folded into a REST controller file just because "it's basically the same feature."
- One Service, many doors. A
MessageHub(WebSocket) and aMessageController(REST) can both legitimately call into the sameMessageService— that's the entire point of the layering. What must never happen is the same business logic written twice, once per transport, because nobody gave the second transport its own adapter. - Calling another API is also a transport concern, just outbound instead of inbound. Wrap external API calls in their own client class (e.g.
PaymentGatewayClient), injected into a Service like any other dependency, instead of reaching forHttpClientad hoc inside business logic. - Same dependency direction as the rules above — this just acknowledges there can be more than one Controller-shaped thing at the top.
Folder structure (folder-based layering — fine for small/medium projects)
ProjectName/
├── Models/ # Entities + BaseModel + Enums
├── Data/ # DbContext + Fluent API configuration
├── Interfaces/ # IRepository<T> + entity-specific repo interfaces
├── Repositories/ # Repository<T> + entity-specific repository extensions
├── Services/ # Business logic
├── DTOs/ # Internal service-to-service data shapes (+ AutoMapper Profiles)
├── ViewModels/ # Front-end contract (+ ResponseViewModel, ErrorCodes)
├── Helpers/ # e.g. AutoMapperHelper
├── Controllers/
└── Program.cs
Once a project grows enough that this gets unwieldy, split each folder into its own project in the same Solution (*.API, *.Core, *.Infrastructure). Don't do this prematurely — folder-based layering is enough until it genuinely isn't.
2. Build order — work through this sequence
- Read the spec before opening the IDE. Pull out scope, actors, modules, business rules. Ordinary properties may be assumed; relationships may not (§4).
- Foundation — solution structure,
BaseModel, naming conventions,DbContextskeleton, DI registration order (§3). - Domain modeling — entities, relationships, enums, migrations (§4).
- Infrastructure / data access — generic repository, query patterns, transactions (§5).
- Business logic — service layer, DTOs/ViewModels, AutoMapper,
ResponseViewModel, validation (§6). - API layer — controllers, routes, status codes, endpoint design (§7).
- Production readiness — logging, caching, stored procedures, migration discipline (§8) — only once the core features actually work.
Don't jump ahead: a Service written before the Repository exists tends to absorb Repository concerns into itself; a Controller written before the Service layer is settled tends to absorb business logic permanently.
3. Foundation
Naming conventions
| Element | Rule | Example |
|---|---|---|
| Entity classes | Singular | Question, not Questions |
DbSet<T> |
Plural | DbSet<Course> Courses |
| Controllers | {Entity}Controller |
CourseController |
| Services | {Entity}Service |
QuestionService |
| DTOs | {Action}{Entity}Dto |
CreateQuestionDto |
| ViewModels | {Action}{Entity}ViewModel |
CreateQuestionViewModel |
| Foreign keys | {Entity}Id |
CourseId |
| Booleans | Is/Has + adjective |
IsDeleted, IsCorrect |
A function that keeps growing is a red flag — it's accumulating more than one responsibility; split it (Single Responsibility, applied literally to function size).
Don't repeat a class's own scope inside its method names. Inside QuestionService, the entity is already implied by the class — prefer Create(), GetById(), Delete() over CreateQuestion(), GetQuestionById(), DeleteQuestion(). Keep the fuller form only where it earns its keep: a class genuinely spanning more than one entity, or a method whose name needs to read clearly outside the class's own context.
BaseModel — every entity inherits this
public class BaseModel
{
public int ID { get; set; }
public bool Deleted { get; set; } = false;
public DateTime CreatedDate { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedDate { get; set; }
}
int, not a generic ID type.intis a better clustered index. Making the ID generic (sometimesint, sometimesGUID) forces everything that references an ID (e.g. "who performed this action") to be generic too — needless complexity for the generic repository and generic actions built on top of it.Deletedexists because soft delete is the default ~99% of the time — see §4.- Navigation collections are typed as the interface (
ICollection<Choice>, notList<Choice>), and foreign keys are declared explicitly alongside the navigation property with[ForeignKey]— so a caller isn't forced to load the full related object just to read its ID.
DbContext setup checklist
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder
.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking) // default OFF — opt in with .AsTracking()
.LogTo(log => Debug.WriteLine(log), LogLevel.Information) // Information only — Default is far too noisy
.EnableSensitiveDataLogging(); // ⚠️ DEV ONLY — strip before production
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Disable cascade delete everywhere — we control soft-delete cascades by hand
var cascadeFKs = modelBuilder.Model.GetEntityTypes()
.SelectMany(t => t.GetForeignKeys())
.Where(fk => !fk.IsOwnership && fk.DeleteBehavior == DeleteBehavior.Cascade);
foreach (var fk in cascadeFKs) fk.DeleteBehavior = DeleteBehavior.NoAction;
}
- NoTracking by default because ~99% of API calls are reads; tracking overhead with no payoff. Use
.AsTracking()explicitly only where you intend to mutate. - Connection string in
appsettings.json, never hardcoded in the context — teammates' local SQL setups differ. - The loop-based cascade override automatically applies to any entity added later — don't repeat this manually per migration.
DI registration order in Program.cs
builder.Services.AddDbContext<Context>(o => o.UseSqlServer(connStr));
builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
builder.Services.AddScoped<IQuestionRepository, QuestionRepository>(); // repo extensions
builder.Services.AddScoped<QuestionService>();
builder.Services.AddScoped<ExamService>();
builder.Services.AddAutoMapper(typeof(Program).Assembly);
builder.Services.AddControllers();
AddScoped = one instance per request — the correct lifetime for repositories and services sharing one DbContext.
Configuration — keep it in one place
Prefer centralizing constants and configuration (connection strings, magic numbers, named policies) in one discoverable place — appsettings.json sections, or a single Constants class — rather than scattering them near each point of use. This is partly a style preference, not a hard rule, but it earns its keep: fewer lines overall, and one place to check instead of hunting across files.
Code First, by default
Build new projects Code First. Database First is for legacy databases being reverse-engineered. This doesn't forbid stored procedures or views — they're still built and called through EF normally (§8).
4. Domain modeling
Methodology
- Entity = anything the business gathers data around (Course, Question, Exam, Instructor, Student...).
- Relationship = look for "two entities + a verb" in the spec ("Instructor teaches Course", "Student takes Exam"). The verb is the relationship.
- Never assume a relationship that isn't stated. A wrong assumption (e.g. forcing Question → Quiz as One-to-Many when one question is actually reusable across many exams) cascades into a wrong schema that's expensive to unwind. Ask the product owner instead of guessing.
Foreign keys — always explicit
public class Choice : BaseModel
{
public string Text { get; set; }
public bool IsCorrectChoice { get; set; }
[ForeignKey("Question")]
public int QuestionId { get; set; }
public Question Question { get; set; }
}
So a caller who only needs the ID never has to load the whole related object.
Many-to-many — bridge entity vs. direct navigation
- Bridge link carries extra columns (a grade, a date, a status) → make it an explicit entity.
- Bridge link is a pure association (Post ↔ Tag) → direct navigation is fine.
- The real test: does the relationship represent a fact or event in the real world (an enrollment, a purchase, a booking, an exam attempt)? → explicit entity. Is it just a label/category? → direct navigation is acceptable.
- Give the bridge table its own single-column PK (the inherited
ID), not a composite key of the two FKs — better indexing, faster search, and a clean target if something else needs to relate to this bridge row later. - Starting with direct navigation and discovering later you need an extra column means a full refactor back to an explicit entity — when in doubt that the link might ever need a column, bias toward the explicit entity from the start.
public class ExamQuestion : BaseModel
{
public int Grade { get; set; }
[ForeignKey("Exam")] public int ExamId { get; set; }
[ForeignKey("Question")] public int QuestionId { get; set; }
public Exam Exam { get; set; }
public Question Question { get; set; }
}
Recursive (self-referencing) relationships
A Course's prerequisites are other Courses — Many-to-Many, recursive. Don't model this with inheritance (Prerequisite : Course) — that's a Has-a relationship pretending to be Is-a, and it duplicates data into a new table. Use a bridge entity with two FKs both pointing at Course, and disambiguate the two resulting collections with [InverseProperty] or Fluent API — EF cannot tell them apart on its own.
Where does a property belong — the entity, or the bridge table?
- Describes the relationship itself (e.g. "the grade I assign when adding this question to this exam") → the bridge entity.
- Describes the entity on its own, independent of any relationship → the entity itself.
- A field that depends on which instance of the relationship you're in (a grade that differs exam to exam) always belongs on the bridge, never on either entity.
- A correctness-critical field whose write could race (e.g. a student's chosen answer, with many students answering concurrently) must live in its own table keyed by all the relevant FKs (student, exam, question, choice) — never as a single overwritable field on an entity multiple users touch at once.
Historical / point-in-time data
If a value can change later (a question's grade, a product's price) and you've already recorded a fact that depended on the old value (a student's score, an invoice line), store that value in the bridge/transaction table at the time it happened — don't recompute it later from the current lookup value, or history silently rewrites itself when the lookup value changes. This is not duplication; it's "the value at that moment" vs. the lookup table's "current value."
Soft delete — the full blast radius
Soft delete (Deleted = true, never a real DELETE) is the default ~99% of the time, because hard deletes are unrecoverable and unforgiving of bugs. Once committed to it:
- Every query must filter
!Deleted— centralize this in the generic repository'sGetAll()so it can't be forgotten per-query. - Cascade it to children by hand. Soft-deleting a parent does not soft-delete its children automatically (and cascade delete is disabled anyway, §3) — write the cascade explicitly (see Repository Extensions, §5).
- Every existence check needs
!Deletedtoo. A validation that checks "does this Course exist" without excluding deleted rows will happily validate against a Course that's gone.
Migrations
- Development only. Never run
Add-Migration/Update-Databasedirectly in production — a teammate's manual data fix could be wiped out by a generatedDROP COLUMN. Use hand-written SQL scripts in production instead. - Solo project: delete the
Migrationsfolder anytime. Team project: never delete it — teammates need it to replay your schema changes locally. - Snapshot desync (common when copying entities into a new project without copying
Migrations/): EF thinks nothing exists yet and tries to re-create every table. Fix: delete the newMigrationsfolder → comment out your latest model change →Add-Migration(creates an empty no-op script, snapshot now matches reality) →Update-Database(executes nothing, but syncs the snapshot) → uncomment your change →Add-Migrationagain, which now generates only the real delta. The snapshot updates onAdd-Migration, not onUpdate-Database. - If a generated migration is wrong, don't just delete the file and move on — that leaves the snapshot holding the bad change. Revert (
Update-Databaseto the prior migration) → delete the bad migration file → fixOnModelCreating→Add-Migrationfresh.
5. Repository layer (data access)
Generic repository contract
public interface IRepository<T> where T : BaseModel
{
IQueryable<T> GetAll();
IQueryable<T> Get(Expression<Func<T, bool>> predicate);
Task<T?> GetByIdAsync(int id);
Task<T?> GetByIdWithTrackingAsync(int id);
Task<bool> AnyAsync(Expression<Func<T, bool>> predicate);
Task<T> AddAsync(T entity);
void UpdateInclude(T entity, params string[] modifiedProperties);
void SoftDelete(T entity);
Task<int> SaveChangesAsync();
}
Use this interface even when sure there's only one implementation — it's what makes the data source swappable and the layer testable (Dependency Inversion in practice, not folklore).
The golden async rule
- Returns
IQueryable(query not yet executed —GetAll,Get) → noasync/await. There's no operation to await; it's a deferred description of a query. - Returns realized data (
T,List<T>,int,boolviaFirstOrDefaultAsync,ToListAsync,AnyAsync) → alwaysasync/await.
IQueryable vs IEnumerable — the single most important distinction
IQueryable |
IEnumerable |
|
|---|---|---|
| Execution | Deferred — not run yet | Already materialized in memory |
.Where()/.Select()/.OrderBy() |
Translates to SQL, runs in the database | Runs in memory |
| Returned by | Repository | Service (final layer, §6) |
The danger: if a method returns IQueryable but the caller receives it into an IEnumerable variable, every subsequent .Where()/.Select() silently executes in memory instead of the database — after first pulling the entire table. The tell: an .OrderBy() written in C# that's missing from the generated SQL. Always look at the generated query (via the dev-only logging in §3) to confirm filtering/sorting actually happened server-side.
Never return IQueryable straight out of a Controller and let the serializer execute it — that loses async (the serializer executes synchronously), makes exceptions unhandleable inside the controller, and hands the serializer a job (talking to the database) that isn't its job.
Select over Include — golden rule: never SELECT *
// ❌ Include → SELECT * across both tables, risk of circular references
var data = context.Courses.Include(c => c.Exams).ToList();
// ✅ Select → only the columns actually needed
var data = context.Courses.Where(c => !c.Deleted)
.Select(c => new { CourseName = c.Name, Exams = c.Exams.Select(e => new { e.ID, e.Date }) })
.ToList();
While still in IQueryable form (query not yet executed), related data can be reached directly without .Include() — EF generates the JOIN itself. Also: SQL Server builds indexes around what's filtered and selected; selecting unneeded columns can defeat index usage entirely and turn a sub-millisecond query into several seconds via a full table scan.
CRUD patterns
- Add: EF auto-tracks and inserts a parent's children collection — don't manually loop and assign the FK or
Add()each child;SaveChangesAsync()once, in the Service. - Update — never the two naive ways:
_dbSet.Update(entity)rewrites every column, even unchanged ones, and requires the caller to send the full object (partial payloads silently zero out the rest).- Loading the full entity just to change two fields pulls a
SELECT *that wasn't needed. UpdateInclude(entity, params string[] modifiedProperties)is the fix: attach/locate the entity in theChangeTracker, and for each named property only, copy the new value in and markIsModified = true. Nothing else is touched, nothing else needs to be sent. Pair it with[HttpPatch], not[HttpPut].- For true bulk updates (many rows, no per-row business logic),
ExecuteUpdateAsyncis faster (raw SQL, no tracking) but riskier — a wrongWhereupdates the whole table.UpdateIncludecan't do that; it's scoped to one tracked entity.
- Delete: soft delete is a state change only (
entity.Deleted = true),void— no I/O untilSaveChangesAsync()is called by the Service. - Existence checks use
.AnyAsync(predicate), neverGetByIdfollowed by a null check —Anynever pulls the row into memory. - Empty results are not
null. A query returning a list returns an empty list, notnull, when nothing matches — check.Any()/.Count(), notis null. FirstvsSinglevsFind:First/FirstOrDefaultignores extra matches (fast);Single/SingleOrDefaultthrows if more than one match (use only when uniqueness is a business invariant being verified, not for PK lookups where uniqueness is already guaranteed);Findchecks the in-memory local cache before the database (fine if staleness is acceptable, otherwise use a normal query).
Repository extensions for entity-specific logic
Generic logic stays in Repository<T>. Entity-specific business rules (e.g. "deleting a Question must also soft-delete its Choices") live in an extension that inherits the generic implementation:
public interface IQuestionRepository : IRepository<Question>
{
Task DeleteQuestionAndChoicesAsync(Question question);
}
public class QuestionRepository : Repository<Question>, IQuestionRepository
{
public QuestionRepository(Context context) : base(context) { }
public async Task DeleteQuestionAndChoicesAsync(Question question)
{
SoftDelete(question);
var choices = await _context.Set<Choice>()
.Where(c => c.QuestionId == question.ID && !c.Deleted).ToListAsync();
foreach (var choice in choices) choice.Deleted = true;
}
}
If an entity has no special logic, skip the extension entirely and inject IRepository<TheEntity> directly.
Transactions — all or nothing
Use when one step's success is meaningless without another's (e.g. deleting a question and its choices together). await using var transaction = await _context.Database.BeginTransactionAsync(); — using guarantees the transaction is closed/disposed even on an unexpected error.
Performance discipline inside queries
- Filter in the database, never load-then-filter (
ToList()before.Where()is the classic mistake — it pulls the whole table first). - Never query the database inside a loop (the N+1 problem). Fetch everything needed once, before the loop, into an in-memory
Dictionary, then compare. - Multiple aggregations on the same data → one
GroupByquery, not three separate round trips and not aToList()followed by in-memory math. - Lead
Wherewith the soft-delete filter unless filtering by a clustered-index column (likeID), in which case that comes first. Async/Awaiteverywhere data is actually fetched — it frees the request thread back to the pool while waiting on I/O, which is what lets the API handle many concurrent requests. Never use.Result(blocks the thread). UseConfigureAwait(false)in inner layers (Repository/Service, where the resuming thread doesn't matter); leave it default/trueonly at the outermost endpoint.- Predicate builders for optional multi-field filters (3+ optional search params) beat a chain of
if (x.HasValue) query = query.Where(...)for readability, while still producing one query — and the expression tree drops always-true conditions from the generated SQL automatically.
6. Service layer (business logic)
Why Controllers must not own a Repository directly
Whatever shape it takes — a Controller using Context directly, a Controller injecting three different repositories, or one Repository reaching into another entity's table — it produces the same three diseases: scattered single responsibility, risk of circular dependencies between repositories, and duplicated query logic across every controller that needs the same data.
The Service Layer rule
Whenever the structure feels cornered, the answer is to introduce a layer — here, that's the Service, sitting between Controller and Repository:
Controller → Service → Repository → Database
- Inject a Service to reuse another domain's logic (e.g. inject
QuestionServiceintoExamService), not that domain's Repository — that way "get questions for a course" is written once, inQuestionService, and reused everywhere. - A Service never returns
IQueryable— alwaysIEnumerable/IReadOnlyList. The Service is the last layer before the outside world; by the time data leaves it, the query must already be executed. - A Service doesn't know or care who's calling it. It accepts a DTO and returns data; whether the caller is a Controller, another Service, or a background job is not its concern.
- Circular dependency between two Services? Resolve it at the Controller as an immediate fix, or extract a third Service (e.g.
ExamQuestionService) that both depend on. SaveChangesAsyncis called from the repository tied to the entity actually being committed, after validation and the in-memory changes are staged — don't open blanket access to every repository'sSaveChangesfrom one Service.- Put each action where its domain naturally lives.
SubmitExambelongs onExamService, notStudentService, even though a Student is involved.
Entity vs. DTO vs. ViewModel
| Concept | Role | Example |
|---|---|---|
| Entity | The real database shape | Question, Course |
| DTO | Data shape between Services (internal) | CreateQuestionDto |
| ViewModel | Data shape to/from the front-end | CreateQuestionViewModel |
Using the raw Entity at the API boundary tightly couples the front-end to the database schema, leaks columns the front-end has no business seeing, and breaks the front-end on every schema change. Yes, this means two mapping steps (ViewModel → DTO → Entity) — the cost is consistently smaller than the alternative. A field the front-end never sends but the business requires (e.g. an InstructorId derived from the logged-in user) gets filled in at the Controller before the DTO reaches the Service — that's not the Service's job.
AutoMapper
Manual mapping extension methods are faster, but break inside an EF Select() on an IQueryable — EF can't translate a hand-written mapping method to SQL, so it falls back to pulling everything into memory first (SELECT *) and mapping client-side. AutoMapper avoids this:
public class QuestionProfile : Profile
{
public QuestionProfile()
{
CreateMap<Question, QuestionDto>().ForMember(d => d.Head, o => o.MapFrom(s => s.Title));
CreateMap<Question, CreateQuestionDto>().ReverseMap();
}
}
Every mapped class needs its own profile entry, or AutoMapper throws at runtime. Map<T>() works on a single object (in-memory); ProjectTo<T>()/.Project<T>() works on an IQueryable and does translate to SQL, selecting only the destination type's properties — that's the whole performance win.
Decouple from the provider with a static helper instead of injecting IMapper everywhere:
public static class AutoMapperHelper
{
public static IMapper Mapper { get; set; }
public static T Map<T>(this object source) => Mapper.Map<T>(source);
public static IQueryable<T> Project<T>(this IQueryable source) => source.ProjectTo<T>(Mapper.ConfigurationProvider);
}
// Program.cs, once: AutoMapperHelper.Mapper = app.Services.GetRequiredService<IMapper>();
Swapping mapping libraries later touches only this one helper.
ResponseViewModel<T> — one response shape, always
public record ResponseViewModel<T>(T? Data, bool IsSuccess, string? Message = "", ErrorCode? ErrorCode = null)
{
public static ResponseViewModel<T> Success(T data, string? message = "") => new(data, true, message, null);
public static ResponseViewModel<T> Failure(ErrorCode errorCode, string? message = null) => new(default, false, message, errorCode);
}
- Pair with a categorized
ErrorCodeenum (100-199for Question errors,200-299for Exam errors, etc.) — the front-end branches on the code, never on the message text (which can change with localization). - The Controller action's return type must be
IActionResult/Task<IActionResult>, neverResponseViewModel<T>directly — returning the wrapper type directly forces the HTTP status to always be200, even on failure, which breaks any middleware or front-end logic keyed off status codes. Combine both: the right status code (Ok,NotFound,BadRequest) wrapping theResponseViewModelbody. - Never return a bare
true/falseor a raw string — the caller can't branch on why it failed.
DTO/ViewModel golden rules
- A new endpoint gets its own DTO, even if it currently looks identical to an existing one — a different endpoint means different business logic, and they will diverge.
- Create DTO ≠ Update DTO — an Update that doesn't need to touch every field shouldn't share a shape that implies it does.
- Never return the raw Entity, even if it currently matches the DTO 1:1 — decoupling now avoids breaking the front-end the moment the business changes.
- A DTO never embeds another Entity — if it needs related data, that's another DTO (
ChoiceDto), not theChoiceentity. - More than 3 parameters → wrap them in a class.
- AutoMapper only selects what's in the destination DTO — a bloated, reused DTO is what drags extra columns along, not AutoMapper itself.
- One DTO per feature. If two front-end screens need almost the same data, the moment their needs diverge even slightly, split into two endpoints with two DTOs — don't let one shared endpoint slowly accrete fields neither screen actually needs (this gets brutal on a frequently-hit screen like a home page).
- Records, not classes, for DTOs/ViewModels — they're read-only data by nature; records express that and read better.
- Receiving a collection from the front-end (e.g. a list of IDs)? Type it
IEnumerable, notICollection—ICollectionimplies it might be mutated in memory, which it won't be for inbound read-only data.
Validation rules
- Every incoming ID gets validated against the database before use — yes, even if that means several round-trips; validate before applying any business logic, not after.
- Use
.AnyAsync()for existence checks, neverGetById— confirming existence doesn't need the whole object in memory. - Partial validation that only catches the all-or-nothing case is a bug. If a request sends 5 question IDs and only 1 is invalid, a check like
!existingQuestions.Any()silently lets the other 4 invalid ones through too — validate every item, not just whether anything at all matched. - Don't forget
!Deletedin any existence check (§4). - There's no generic "Update everything" method in real business logic. Specific actions (
ChangeQuestionLevel) exist because each one carries its own preconditions — a god-methodUpdatehides which fields actually have business rules attached.
7. API layer (controllers & endpoints)
Controller rules
- Talks to a Service only — never a Repository or
DbContext. - Receives ViewModels, maps to DTOs before calling the Service.
- Returns
IActionResultwrappingResponseViewModel. - Contains no business logic — it's an orchestrator, nothing more. Validation, scoring, filtering: all belong in the Service.
Routing & HTTP verbs
[ApiController]
[Route("api/[controller]")] // api/Question
public class QuestionController : ControllerBase
{
[HttpGet] // GET api/Question
[HttpGet("{id}")] // GET api/Question/5
[HttpPost] // POST api/Question
[HttpPatch] // PATCH api/Question ← partial update (UpdateInclude)
[HttpDelete("{id}")] // DELETE api/Question/5 ← soft delete
}
Compose routes for non-CRUD actions descriptively: POST api/Exam/assign, GET api/Exam/{id}/with-questions, POST api/Exam/create-random. [action]-based routing ([Route("[controller]/[action]")]) exists, but the RESTful conventions above are the more standard default.
Endpoint design
- 1 endpoint = 1 feature. Don't let two front-end screens share one endpoint just because their current data needs overlap — the first time one screen needs more fields, the other inherits payload it doesn't need (worst on a high-traffic screen).
- Parent + children created together → one endpoint, one payload. Adding a question with its choices is one POST with both in the body — don't force the front-end to call a separate
Choiceendpoint per choice. Split into separate endpoints only if the business genuinely allows editing a child independently later (e.g.EditChoice). - A request type made of Entities (not DTOs) inherits the Entity's required-by-default navigation properties, which ASP.NET's model validator will then wrongly treat as mandatory fields on the wire — define request shapes with DTOs containing only what the front-end actually sends.
Common controller mistakes to avoid
Contextinjected directly into a controller..Include()used where a.Select()projection would do.- An existence check that forgets
!Deleted. - Returning a bare bool/string instead of
ResponseViewModel. - Returning the same object the client just sent, instead of something meaningful to the next step.
- Forgetting
SaveChangesAsync()after staging changes — the in-memory logic ran, nothing persisted. - A
[NotMapped]property used to "store" per-request state (e.g. a user's exam answer) — it isn't actually persisted; it dies with the request. - A method name that doesn't match what it does (
InstructorAddExamthat ignores the Instructor parameter) — name for the real behavior.
8. Production readiness — only once the core features work
Logging
LogTo(..., LogLevel.Information) + EnableSensitiveDataLogging() are development-only. Strip/comment EnableSensitiveDataLogging before deploying — parameter values (including things like passwords or IDs) can leak into logs otherwise. Use the generated SQL log to diagnose whether a slow endpoint is slow because of the query itself (run it directly against the DB to confirm) or because of in-memory work in the action — and to catch silent IEnumerable-instead-of-IQueryable mistakes (an OrderBy written in C# that's missing from the generated SQL).
Caching
Reach for caching when data changes rarely and the query behind it is expensive (lots of joins). Populate the cache on first request; serve every subsequent request from cache. For a real deployment, Redis as a distributed cache fits naturally behind the same IRepository<T> abstraction — callers keep talking to "the repository" without knowing whether data came from SQL or Redis. If the underlying SQL data changes, use a message broker to keep the cache in sync rather than hand-rolled invalidation logic scattered around.
Stored procedures
Reach for these when a query needs many complex joins. SQL Server caches an execution plan for a stored procedure after trying several and picking the fastest — EF, by contrast, treats every LINQ query as new. Using stored procedures doesn't conflict with EF usage elsewhere in the same project; wrap SP calls in their own Service/Repository and inject normally. Pass limits (e.g. "top 1") as a parameter to the SP rather than loading everything and filtering in memory. Compiled Queries are a lighter-weight partial fix for plan-caching inside plain EF when a full SP feels like overkill.
Polyglot persistence
It's normal and expected to mix data sources in one project — SQL Server for relational/transactional data, a NoSQL store for flexible-schema documents, Redis for caching/sessions. The Repository pattern is what makes this painless: callers depend on the Repository interface, not on which engine answers it.
Migrations in production
Never run EF migrations directly against production (§4) — hand-written SQL scripts instead, to avoid an in-flight teammate edit causing an unintended DROP COLUMN/table.
Authentication & authorization
Deliberately deferred on a first project — treat all users as authorized so the project stays focused on business logic and architecture. JWT, roles, and permissions are a separate, later concern; don't bolt them on here unless asked.
Concurrency
- Multithreading/concurrency can run on a single core (the OS interleaves it); parallelism genuinely requires multiple cores, with explicit control over which work goes to which core — reach for it when processing large in-memory datasets (e.g. 10k+ records) with heavy per-item computation, splitting into chunks per core.
- Catching
DbUpdateConcurrencyExceptionis necessary but not sufficient when two users might edit the same row simultaneously — thecatchblock needs real conflict-resolution logic, not an empty/boilerplate handler.
9. Performance golden rules (quick reference)
| Rule | Why |
|---|---|
Never SELECT * — Select() over Include() |
Indexing depends on what's filtered+selected; unnecessary columns can force a full table scan |
NoTracking by default |
~99% of reads don't need it; tracking is pure overhead without it |
| Never query the DB inside a loop | N+1 — fetch once before the loop, compare via an in-memory Dictionary |
.AnyAsync() for existence checks |
Avoids loading the full object just to check it exists |
Filter in the database, not after .ToList() |
In-memory filtering after materializing defeats the database's own optimizer |
Async/Await throughout |
Frees the request thread back to the pool — this is what scalability is made of |
Repositories return IQueryable |
Lets callers compose further filters that still compile down to one DB query |
UpdateInclude over a full Update() |
Updates only the changed columns, no full-payload requirement |
ExecuteUpdateAsync for true bulk updates |
Raw SQL, no tracking — fastest, but scope the Where carefully |
One GroupBy query instead of several aggregate queries |
Same round trip, not three |
10. Decision routing — "where does this go?"
- Talking to the database/EF directly? → Repository, behind
IRepository<T>(or an extension interface). - A business rule, workflow, or "when X happens, do A then B"? → Service.
- Shaping/receiving an HTTP request or response? → Controller (thin) + ViewModel.
- Real-time push, a non-HTTP protocol, or calling OUT to another API/service? → its own Transport adapter (a Hub, a gRPC service, an outbound Client) — same Services underneath, never duplicated logic (§1).
- Data passed between Services? → DTO.
- Data crossing the wire to/from the front-end? → ViewModel.
- Mapping between any two of the above? → AutoMapper Profile, accessed via the static helper.
- A uniform success/failure envelope for any endpoint? →
ResponseViewModel<T>+ErrorCode. - A query that needs heavy, complex joins and runs often? → consider a Stored Procedure (§8), not a bigger LINQ query.
- Data that rarely changes and is expensive to compute? → Caching (§8) — not a default, add when it's actually justified.
If a piece of code answers more than one of these at once, that's the layering smell — split it.
11. Scope — what NOT to add by default
Authentication/Authorization, Redis/distributed caching, Stored Procedures, and Polyglot Persistence are all documented above as legitimate next steps — not defaults for a new project. Add them only when asked, or when a concrete, current problem (a measured slow query, a real multi-tenant auth requirement) justifies it. Bolting these on prematurely is its own form of over-engineering — the same instinct that says "don't reach for a Smart Enum until the business genuinely forces it" applies here too.
12. Final review — read it as the next developer on the team
- Does a search for
DbContext/ContextoutsideRepositories/andData/come back empty? - Does every Repository method that returns realized data use
async, and every method returningIQueryableskip it? - Does every Service method return
IEnumerable/a concrete type, neverIQueryable? - Is there a DTO or ViewModel reused across two endpoints with genuinely different needs? Split it.
- Does every Controller action return
IActionResultwrappingResponseViewModel<T>, with a real HTTP status code? - Did every existence/validation check include
!Deleted? - Is
EnableSensitiveDataLogging()still active anywhere near a production config? - Could a relationship in the schema have been assumed instead of confirmed against the spec? Flag it.
- Does any class or method name redundantly repeat its own scope (§3) —
QuestionService.CreateQuestion()instead of.Create()? - If the project has more than one transport (WebSocket, queue, external API), does each have its own adapter, or did logic leak into a REST controller (§1)?
- Is there a block of code or comments doing a job two clear lines could do? Tighten it.
13. Reporting
When a phase (or the project) is done, report:
- What was built, mapped to the phase/layer it belongs to.
- Relationships or business rules that had to be assumed because the spec didn't say — flag these explicitly for the user/product owner to confirm; don't silently bake in a guess.
- Deferred by design: Auth, caching, stored procedures, polyglot persistence — note which of these were left out deliberately (§11) and why, so it's a visible decision, not an oversight.
- Anything spotted but left alone: a bug outside today's scope, a missing feature the spec implies — name it so it's a known item, not a silent gap.