Skip to content
Skillv1.0.0

dotnet-layered-api-architect

Build or extend ASP.NET Core Web API projects on a strict Controller → Service → Repository → Database layered architecture with EF Core Code First. Use by default for any new .NET/C# backend project,

by Abdelrhman-elsaeed(0) 0 installs
Free
Sign in to install

Free account. Installing gives you the manifest plus copy-paste snippets.

See reviews

About

Imported from Abdelrhman-elsaeed/English-Platform (.agents/skills/dotnet-layered-api-architect/SKILL.md). Install upstream with npx 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 returns IQueryable. 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

  1. Controller → Service only. Never inject a Repository or DbContext into a Controller.
  2. 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.
  3. Repository → DbContext only. 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 a MessageController (REST) can both legitimately call into the same MessageService — 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 for HttpClient ad 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

  1. Read the spec before opening the IDE. Pull out scope, actors, modules, business rules. Ordinary properties may be assumed; relationships may not (§4).
  2. Foundation — solution structure, BaseModel, naming conventions, DbContext skeleton, DI registration order (§3).
  3. Domain modeling — entities, relationships, enums, migrations (§4).
  4. Infrastructure / data access — generic repository, query patterns, transactions (§5).
  5. Business logic — service layer, DTOs/ViewModels, AutoMapper, ResponseViewModel, validation (§6).
  6. API layer — controllers, routes, status codes, endpoint design (§7).
  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. int is a better clustered index. Making the ID generic (sometimes int, sometimes GUID) 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.
  • Deleted exists because soft delete is the default ~99% of the time — see §4.
  • Navigation collections are typed as the interface (ICollection<Choice>, not List<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's GetAll() 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 !Deleted too. 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-Database directly in production — a teammate's manual data fix could be wiped out by a generated DROP COLUMN. Use hand-written SQL scripts in production instead.
  • Solo project: delete the Migrations folder 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 new Migrations folder → 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-Migration again, which now generates only the real delta. The snapshot updates on Add-Migration, not on Update-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-Database to the prior migration) → delete the bad migration file → fix OnModelCreatingAdd-Migration fresh.

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) → no async/await. There's no operation to await; it's a deferred description of a query.
  • Returns realized data (T, List<T>, int, bool via FirstOrDefaultAsync, ToListAsync, AnyAsync) → always async/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 the ChangeTracker, and for each named property only, copy the new value in and mark IsModified = 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), ExecuteUpdateAsync is faster (raw SQL, no tracking) but riskier — a wrong Where updates the whole table. UpdateInclude can'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 until SaveChangesAsync() is called by the Service.
  • Existence checks use .AnyAsync(predicate), never GetById followed by a null check — Any never pulls the row into memory.
  • Empty results are not null. A query returning a list returns an empty list, not null, when nothing matches — check .Any()/.Count(), not is null.
  • First vs Single vs Find: First/FirstOrDefault ignores extra matches (fast); Single/SingleOrDefault throws if more than one match (use only when uniqueness is a business invariant being verified, not for PK lookups where uniqueness is already guaranteed); Find checks 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 GroupBy query, not three separate round trips and not a ToList() followed by in-memory math.
  • Lead Where with the soft-delete filter unless filtering by a clustered-index column (like ID), in which case that comes first.
  • Async/Await everywhere 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). Use ConfigureAwait(false) in inner layers (Repository/Service, where the resuming thread doesn't matter); leave it default/true only 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 QuestionService into ExamService), not that domain's Repository — that way "get questions for a course" is written once, in QuestionService, and reused everywhere.
  • A Service never returns IQueryable — always IEnumerable/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.
  • SaveChangesAsync is 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's SaveChanges from one Service.
  • Put each action where its domain naturally lives. SubmitExam belongs on ExamService, not StudentService, 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 ErrorCode enum (100-199 for Question errors, 200-299 for 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>, never ResponseViewModel<T> directly — returning the wrapper type directly forces the HTTP status to always be 200, 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 the ResponseViewModel body.
  • Never return a bare true/false or a raw string — the caller can't branch on why it failed.

DTO/ViewModel golden rules

  1. 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.
  2. Create DTO ≠ Update DTO — an Update that doesn't need to touch every field shouldn't share a shape that implies it does.
  3. 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.
  4. A DTO never embeds another Entity — if it needs related data, that's another DTO (ChoiceDto), not the Choice entity.
  5. More than 3 parameters → wrap them in a class.
  6. AutoMapper only selects what's in the destination DTO — a bloated, reused DTO is what drags extra columns along, not AutoMapper itself.
  7. 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).
  8. Records, not classes, for DTOs/ViewModels — they're read-only data by nature; records express that and read better.
  9. Receiving a collection from the front-end (e.g. a list of IDs)? Type it IEnumerable, not ICollectionICollection implies 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, never GetById — 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 !Deleted in 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-method Update hides which fields actually have business rules attached.

7. API layer (controllers & endpoints)

Controller rules

  1. Talks to a Service only — never a Repository or DbContext.
  2. Receives ViewModels, maps to DTOs before calling the Service.
  3. Returns IActionResult wrapping ResponseViewModel.
  4. 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 Choice endpoint 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

  • Context injected 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 (InstructorAddExam that 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 DbUpdateConcurrencyException is necessary but not sufficient when two users might edit the same row simultaneously — the catch block 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/Context outside Repositories/ and Data/ come back empty?
  • Does every Repository method that returns realized data use async, and every method returning IQueryable skip it?
  • Does every Service method return IEnumerable/a concrete type, never IQueryable?
  • Is there a DTO or ViewModel reused across two endpoints with genuinely different needs? Split it.
  • Does every Controller action return IActionResult wrapping ResponseViewModel<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:

  1. What was built, mapped to the phase/layer it belongs to.
  2. 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.
  3. 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.
  4. 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.

Use it

Copy one of these into your project. Installing also returns the manifest and these snippets.

yaml
targets:
  - https://api.opensmartroute.ai/api/v1/registry/abdelrhman-elsaeed-english-platform-dotnet-layered-api-a-c7828e/manifest   # or paste the manifest below

Manifest

An Open Capability Manifest: the router reads it to know what this does, what it costs and when to pick it.

abdelrhman-elsaeed-english-platform-dotnet-layered-api-a-c7828e.ocm.jsonjson
{
  "ocm": "1",
  "id": "abdelrhman-elsaeed-english-platform-dotnet-layered-api-a-c7828e",
  "kind": "skill",
  "name": "dotnet-layered-api-architect",
  "description": "Build or extend ASP.NET Core Web API projects on a strict Controller → Service → Repository → Database layered architecture with EF Core Code First. Use by default for any new .NET/C# backend project, and whenever the task touches: solution/folder structure, BaseModel and shared abstractions, DbContext setup, entity/relationship modeling (one-to-many, many-to-many bridge entities, recursive/self-referencing relations, historical data), the Generic Repository pattern, EF Core query patterns (IQueryable vs IEnumerable, Select vs Include, async rules), the service layer and business logic, DTOs/ViewModels/AutoMapper, the ResponseViewModel + ErrorCode pattern, controller/endpoint design, or production-readiness (logging, caching, stored procedures, migrations). This is the user's personal architecture playbook distilled from a backend course — apply it even if the user doesn't say 'clean architecture' or name a specific phase.",
  "publisher": "Abdelrhman-elsaeed",
  "version": "1.0.0",
  "capabilities": {
    "domains": [
      "coding",
      "data_analysis"
    ],
    "tags": [
      "skill-md",
      "github"
    ],
    "languages": [
      "en"
    ]
  },
  "quality_prior": 0.6,
  "examples": [
    "Build or extend ASP.NET Core Web API projects on a strict Controller → Service → Repository → Database layered architecture with EF Core Code First. Use by default for any new .NET/C# backend project, and whenever the task touches: solution/folder structure, BaseModel and shared abstractions, DbContext setup, entity/relationship modeling (one-to-many, many-to-many bridge entities, recursive/self-referencing relations, historical data), the Generic Repository pattern, EF Core query patterns (IQueryable vs IEnumerable, Select vs Include, async rules), the service layer and business logic, DTOs/ViewModels/AutoMapper, the ResponseViewModel + ErrorCode pattern, controller/endpoint design, or production-readiness (logging, caching, stored procedures, migrations). This is the user's personal architecture playbook distilled from a backend course — apply it even if the user doesn't say 'clean architecture' or name a specific phase."
  ],
  "primary": false,
  "metadata": {
    "source": {
      "provider": "github",
      "repository": "https://github.com/Abdelrhman-elsaeed/English-Platform",
      "path": ".agents/skills/dotnet-layered-api-architect/SKILL.md",
      "ref": "856c95782c042a703b87eb9daa716154d2b6e518",
      "url": "https://github.com/Abdelrhman-elsaeed/English-Platform/blob/856c95782c042a703b87eb9daa716154d2b6e518/.agents/skills/dotnet-layered-api-architect/SKILL.md",
      "key": "Abdelrhman-elsaeed/English-Platform/.agents/skills/dotnet-layered-api-architect/SKILL.md"
    }
  },
  "instructions": "# .NET Layered API Architect\n\n**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 cod",
  "cost": {
    "context_tokens": 11089
  }
}

Fetch it by URL: GET /api/v1/registry/abdelrhman-elsaeed-english-platform-dotnet-layered-api-a-c7828e/manifest?version=1.0.0

Reviews

Star ratings from people who tried it. One review per account; edit yours any time.

No reviews yet. Install it, try it, and be the first to rate it.