This project showcases manual DTO mapping in ASP.NET Core Web API using extension methods. It emphasizes clarity, separation of concerns, and explicit control over data transformation. By avoiding external mapping libraries, the code remains transparent, purposeful, and easy to evolve. The structure highlights how to shape request and response models, configure serialization, and organize endpoint logic around minimal APIs and clean repository abstraction.
- βοΈ Demonstrate clean and explicit DTO mapping without external libraries.
- βοΈ Maintain separation between domain models, transport models, and persistence logic.
- βοΈ Use extension methods for consistent and readable data transformation.
- βοΈ Structure endpoints around Minimal APIs using clear route definitions and typed results.
- βοΈ Configure JSON serialization for safety and predictable contract delivery.
- βοΈ Persist data using EF Core with SQLite in a local development-friendly setup.
This solution is organized for clarity and maintainability. Each folder encapsulates a distinct responsibility, from endpoint design and manual data transformation to repository abstraction and database interaction, making the codebase easy to navigate and extend.
βββ ManualDtoMappingDemo
β βββ Data
β β βββ ProductDB.db # SQLite Database file for local development
β β βββ ProductDbContext.cs # EF Core DbContext configured for SQLite
β βββ Dtos
β β βββ ProductDtos.cs # DTO definitions for create, update, and response
β β βββ CreateProductRequest # Input model for product creation
β β βββ UpdateProductRequest # Input model for product update
β β βββ ProductResponse # Output model returned to clients
β βββ Endpoints
β β βββ ProductEndpoints.cs # Minimal API endpoints grouped under `/products`
β βββ Entities
β β βββ Product.cs # Domain model representing the Product entity
β βββ Mapping
β β βββ ProductDtoExtensions.cs # Extension methods for mapping between entity and DTO
β βββ Repositories
β β βββ IProductRepository.cs # Repository interface for CRUD operations
β β βββ ProductRepository.cs # EF Core implementation of the repository pattern
β βββ appsettings.json # JSON config including SQLite connection string
β βββ Program.cs # Main entry point. Application startup, service registration, and endpoint mapping
All service registrations and application setup logic are defined in Program.cs. This includes configuration for JSON serialization, EF Core with SQLite, scoped repository services, and OpenAPI tooling.
-
π¦
JsonOptionsConfigured via
builder.Services.Configure<JsonOptions>to customize serialization behavior:- Ignores null values when writing JSON (
DefaultIgnoreCondition). - Uses Pascal casing for property names and dictionary keys (
PropertyNamingPolicyandDictionaryKeyPolicy). - Enables case-insensitive property matching during deserialization.
- Handles circular references using
ReferenceHandler.IgnoreCycles.
- Ignores null values when writing JSON (
-
ποΈ EF Core with SQLite
Registered via
builder.Services.AddDbContext<ProductDbContext>, with connection string loaded fromappsettings.json: -
ποΈ Repository Pattern
Scoped repository services are registered to allow dependency injection in endpoints, ensuring clean separation of concerns.
-
π OpenAPI Integration
Minimal OpenAPI support enabled through
builder.Services.AddOpenApi()andapp.MapOpenApi()in development. -
π HTTPS Redirection
Configured with
app.UseHttpsRedirection()to enforce secure requests. -
π§© Endpoint Mapping
Routes are grouped and registered using
ProductEndpoints.MapProductEndpoints(app)to keep definitions modular and encapsulated.
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"Database": "Data Source=Data\\ProductDB.db"
}
}var builder = WebApplication.CreateBuilder (args);
// Add services to the container.
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi ();
builder.Services.Configure<JsonOptions> (options =>
{
// Configure JSON serializer to ignore null values during serialization
options.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
// Configure JSON serializer to use Pascal case for property names during serialization
options.SerializerOptions.PropertyNamingPolicy = null;
// Configure JSON serializer to use Pascal case for key's name during serialization
options.SerializerOptions.DictionaryKeyPolicy = null;
// Ensure JSON property names are not case-sensitive during deserialization
options.SerializerOptions.PropertyNameCaseInsensitive = true;
// Prevent serialization issues caused by cyclic relationships in EF Core entities
options.SerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
// Ensure the JSON output is consistently formatted for readability.
// Not to be used in Production as the response message size could be large
// options.SerializerOptions.WriteIndented = true;
});
builder.Services.AddDbContext<ProductDbContext> (options =>
{
options.UseSqlite (builder.Configuration.GetConnectionString ("Database"));
});
builder.Services.AddScoped<IProductRepository, ProductRepository> ();
var app = builder.Build ();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment ())
{
app.MapOpenApi ();
}
app.UseHttpsRedirection ();
new ProductEndpoints ().MapProductEndpoints (app);
app.Run ();This project uses SQLite as a lightweight, embedded database ideal for local development and testing. EF Core handles schema generation and persistence automatically based on the Product entity.
-
π Database File Location
The database file is created at
Data\ProductDB.dbupon application startup. -
π Connection String
Defined in
appsettings.jsonunder theConnectionStrings:Databasekey. -
ποΈ EF Core Setup
SQLite is registered as the provider through
AddDbContext<ProductDbContext>(). The correspondingDbContextdeclares a singleDbSet<Product>property for interacting with theProductstable. -
π§ͺ Development Friendly
Because the database is file-based and versioned locally, it's easy to reset or inspect during iterative development.
public class ProductDbContext (DbContextOptions<ProductDbContext> options) : DbContext (options)
{
public DbSet<Product> Products { get; set; }
}The Product class represents the domain entity for this demo. It defines the core business data that gets persisted in the SQLite database via EF Core. This entity is intentionally simple, focusing on essential product fields.
public class Product
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public int Quantity { get; set; }
public decimal Price { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.Now;
}Idis a globally unique identifier for the product.Name,Description,Quantity, andPricerepresent core product attributes.CreatedAtis set at the time of instantiation, providing audit context.- EF Core maps this class to the Products table using
DbSet<Product>inProductDbContext.
This entity is mapped to external contracts through extension methods, ensuring that internal fields like CreatedAt are excluded unless explicitly needed.
The ProductDtos.cs file defines three record types that shape request and response payloads. Each DTO serves a distinct purpose in endpoint communication, avoiding direct exposure of domain entities.
public sealed record CreateProductRequest (string Name, string Description, int Quantity, decimal Price);π Used in the
POST /productsendpoint. Maps to theProductentity via extension methods and defines the shape of client input during creation. It omitsIdandCreatedAt, as those values are generated by the server.
public sealed record UpdateProductRequest (Guid Id, string Name, string Description, int Quantity, decimal Price);π Used in the
PUT /productsendpoint. Includes theIdof the record being updated. Allows overwriting all editable product fields.
Returned to clients in response payloads. Exposes key product info along with server-assigned metadata like Id while excluding CreatedAt.
public sealed record ProductResponse (Guid Id, string Name, string Description, int Quantity, decimal Price);π Used as the response model exclusively in
GET /productsandGET /products/{id}endpoints. Intentionally excludes internal fields likeCreatedAtfor a focused contract.
The repository layer encapsulates data access logic, abstracting EF Core interactions behind a clean interface. This promotes separation of concerns and simplifies testing and endpoint composition.
Defines the contract for CRUD operations related to the Product entity.
public interface IProductRepository
{
Task<IEnumerable<Product>> GetAllAsync (CancellationToken cancellationToken);
Task<Product?> GetByIdAsync (Guid id, CancellationToken cancellationToken);
Task AddAsync (Product product, CancellationToken cancellationToken);
Task UpdateAsync (Product product, CancellationToken cancellationToken);
Task DeleteAsync (Product product, CancellationToken cancellationToken);
}π Promotes consistency and testability by abstracting persistence concerns. Injected into endpoints for direct use without exposing
DbContext.
Implements the IProductRepository interface using EF Core's ProductDbContext.
public class ProductRepository : IProductRepository
{
private readonly ProductDbContext _dbContext;
public ProductRepository (ProductDbContext dbContext)
=> _dbContext = dbContext;
public async Task<IEnumerable<Product>> GetAllAsync (CancellationToken cancellationToken)
{
return await _dbContext.Products
.AsNoTracking()
.ToListAsync (cancellationToken);
}
public async Task<Product?> GetByIdAsync (Guid id, CancellationToken cancellationToken)
{
return await _dbContext.Products
.AsNoTracking ()
.FirstOrDefaultAsync (p => p.Id == id, cancellationToken);
}
public async Task AddAsync (Product product, CancellationToken cancellationToken)
{
_dbContext.Products.Add (product);
await _dbContext.SaveChangesAsync (cancellationToken);
}
public async Task UpdateAsync (Product product, CancellationToken cancellationToken)
{
await _dbContext.Products
.Where (p => p.Id == product.Id)
.ExecuteUpdateAsync (
setters => setters
.SetProperty (p => p.Name, product.Name)
.SetProperty (p => p.Description, product.Description)
.SetProperty (p => p.Quantity, product.Quantity)
.SetProperty (p => p.Price, product.Price)
.SetProperty (p => p.CreatedAt, product.CreatedAt),
cancellationToken);
}
public async Task DeleteAsync (Product product, CancellationToken cancellationToken)
{
await _dbContext.Products
.Where (p => p.Id == product.Id)
.ExecuteDeleteAsync (cancellationToken);
}
}π GET methods Use EF Core's
AsNoTracking()for efficient read-only access. Primary key lookup is performed usingFirstOrDefaultAsync()with a predicate filter.
π
ExecuteUpdateAsync()andExecuteDeleteAsync()directly apply changes without using EF's change tracker so noSaveChangesAsync()required. These direct SQL execution methods require EF Core 7.0 or later.
EF Core offers two ways to retrieve entities by primary key, each with distinct behavior and tradeoffs:
-
FindAsync(id)- πΉ Behavior: Uses internal tracking and key metadata to locate entities.
- β Pros: Fast for primary keys. May return cached entities if already tracked.
- β Cons: Doesn't support
.AsNoTracking()or eager loading. Less flexible for queries.
-
FirstOrDefaultAsync(...)- πΉ Behavior: Executes full LINQ query with filter predicates.
- β
Pros: Works with
.AsNoTracking(). Extendable with includes, joins, and filters. - β Cons: Slightly slower for pure key lookups. Always hits the database.
- β
This demo intentionally uses
FirstOrDefaultAsync()for predictable behavior, explicit filtering, and clean separation from EF Coreβs change tracking especially in read-only scenarios.
Other query methods exist, but these two are the most common for primary key lookups in typical CRUD APIs.
This layer is registered as a scoped dependency in Program.cs, ensuring a fresh context per request and promoting thread safety during data operations.
This section defines extension methods used to convert between domain entities and DTOs. Manual mapping ensures precise control over which fields are exposed or consumed, reinforcing separation between internal models and transport contracts.
public static ProductResponse ToDto (this Product product)
=> new (product.Id, product.Name, product.Description, product.Quantity, product.Price);π Used in the
GET /productsandGET /products/{id}endpoints to return a clean representation of product data to clients. Internal fields likeCreatedAtare intentionally excluded.
public static Product ToEntity (this CreateProductRequest request)
=> new ()
{
Name = request.Name,
Description = request.Description,
Quantity = request.Quantity,
Price = request.Price,
CreatedAt = DateTime.Now
};π Used in the
POST /productsendpoint. Creates a new domain entity with a generatedIdand defaultCreatedAt. Ensures the request model is correctly translated for persistence.
public static Product ToEntity (this UpdateProductRequest request, Product existingProduct)
=> new()
{
Id = request.Id,
Name = request.Name,
Description = request.Description,
Quantity = request.Quantity,
Price = request.Price,
// Since, PUT updates the entire record, we need to preserve the CreatedAt column value.
// Otherwise, it will be overwritten with a blank value since it is not part of the UpdateProductRequest.
// This is a common pattern to ensure audit fields remain intact during updates.
CreatedAt = existingProduct.CreatedAt
};π Used in the
PUT /productsendpoint. Reconstructs the product entity based on the incoming update payload. Caller is responsible for ensuringIdvalidity and preserving audit fields likeCreatedAt.
These extension methods are lightweight, readable, and easy to locate. They embody the principle of explicit transformation while keeping the mapping logic decoupled from both DTOs and entities.
While this demo uses flat DTOs and entities, manual mapping scales to more complex scenarios, such as:
- Entities with nested objects or collections (e.g.,
OrderwithOrderItems). - DTOs that flatten or reshape data for specific API contracts.
For nested types, you can compose mapping methods:
// Example: mapping an Order entity with nested OrderItems
public static OrderResponse ToDto(this Order order) =>
new(
order.Id,
order.CustomerName,
order.Items.Select(item => item.ToDto()).ToList()
);This approach keeps mapping logic explicit and testable, even as models grow in complexity.
- Transparency: Manual mapping makes every transformation explicit and easy to debug.
- Performance: Libraries like
AutoMapperuse reflection, which can impact performance, especially with large or complex object graphs. - Control: You avoid accidental field exposure and have full control over contract evolution.
- Future-proofing: Relying on third-party libraries for core logic can introduce risks if the library changes license, becomes unsupported, or introduces breaking changes.
Manual mapping is easy to unit test. Each mapping method is a simple function and can be tested independently, ensuring correctness as your models evolve.
As your application grows and the number of DTOs/entities increases, consider:
- Organizing extension methods by feature or domain (e.g., separate files per aggregate or module).
- Using namespaces to group related mappers.
- Keeping mapping logic close to the models they transform for discoverability.
This section defines the Minimal API endpoints grouped under /products. Each handler leverages manual DTO mapping and the repository pattern to process product-related operations. Mapping logic is centralized in ProductDtoExtensions.cs for clarity and consistency.
| Endpoint Route | HTTP Method | Accepts | Returns |
|---|---|---|---|
/products |
GET | β | IEnumerable<ProductResponse>? |
/products/{id} |
GET | Guid (Route) | ProductResponse? |
/products |
POST | CreateProductRequest | Guid + Response header named Location |
/products |
PUT | UpdateProductRequest | "Update Successful" in plain text |
/products/{id} |
DELETE | Guid (Route) | 404 Not Found or 204 No Content if deleted successfully |
Fetches all products from the database and returns them as a list of ProductResponse.
var productGroup = app.MapGroup ("/products");
productGroup.MapGet ("/", async (
IProductRepository productRepository,
CancellationToken cancellationToken) =>
{
var products = (await productRepository.GetAllAsync (cancellationToken))
.Select (p => p.ToDto ());
return TypedResults.Ok (products);
});π Returns a list of products using
ProductResponseDTO for output. Mapping is performed via.ToDto()extension method making sure internal entity fields likeCreatedAtremain excluded.
Fetches a single product by its Id. Returns NotFound if no match is found.
var productGroup = app.MapGroup ("/products");
productGroup.MapGet ("/{id:Guid}", async (
[FromRoute] Guid id,
IProductRepository productRepository,
CancellationToken cancellationToken) =>
{
var product = await productRepository.GetByIdAsync (id, cancellationToken);
return product is null
? Results.NotFound ()
: TypedResults.Ok (product.ToDto ());
}).WithName ("GetProductById");π Returns a single product using
ProductResponseDTO for output. Mapping is performed via.ToDto()extension method making sure internal entity fields likeCreatedAtremain excluded.
Creates a new product. The client sends a CreateProductRequest DTO.
var productGroup = app.MapGroup ("/products");
productGroup.MapPost ("/", async (
[FromBody] CreateProductRequest request,
IProductRepository productRepository,
HttpContext httpContext,
CancellationToken cancellationToken) =>
{
var product = request.ToEntity ();
await productRepository.AddAsync (product, cancellationToken);
var uri = httpContext
.RequestServices
.GetRequiredService<LinkGenerator> ()
.GetUriByName (httpContext, "GetProductById", new { product.Id });
return TypedResults.Created (uri, product.Id);
});π The request is mapped to a
Productentity using the.ToEntity()extension method. Only the generatedIdis returned in the response body (not a full DTO), and theLocationheader is set for easy retrieval of the created resource.
Fully updates an existing product using the UpdateProductRequest DTO. Returns status messages based on operation outcome.
var productGroup = app.MapGroup ("/products");
productGroup.MapPut ("/", async (
[FromBody] UpdateProductRequest request,
IProductRepository productRepository,
CancellationToken cancellationToken) =>
{
if (request.Id == Guid.Empty)
return Results.BadRequest ("Product Id is required");
var existingProduct = await productRepository.GetByIdAsync (request.Id, cancellationToken);
if (existingProduct is null)
return Results.NotFound ();
await productRepository.UpdateAsync(request.ToEntity(existingProduct), cancellationToken);
return TypedResults.Ok ("Update Successful");
});π The entity is reconstructed manually using
.ToEntity()extension method andCreatedAtis preserved. A plain text status is returned. For missing or unknown IDs, appropriate HTTP response is generated (400 Bad Request,404 Not Found).
Deletes a product by its Id. Returns NoContent if successful.
var productGroup = app.MapGroup ("/products");
productGroup.MapDelete ("/{id:Guid}", async (
[FromRoute] Guid id,
IProductRepository productRepository,
CancellationToken cancellationToken) =>
{
var product = await productRepository.GetByIdAsync (id, cancellationToken);
if (product is null)
return Results.NotFound ();
await productRepository.DeleteAsync (product, cancellationToken);
return TypedResults.NoContent ();
});π Operates directly on entity from repository. No DTOs used. Ensures safe deletion with null check.
π Request samples sourced from ManualDtoMappingDemo.http
This section demonstrates practical request/response samples for each endpoint in the /products group. It reinforces DTO usage and expected behavior without diving into source code.
@HostAddress = http://localhost:5122GET {{HostAddress}}/products
Content-Type: none
###[
{
"Id": "0196960d-0c2c-7f11-a2c9-96023bfd93c3",
"Name": "Gaming Laptop",
"Description": "A high-performance laptop with advanced graphics for gaming.",
"Quantity": 10,
"Price": 999.99
},
{
"Id": "0196960d-4839-7971-8eb7-a37633230f2b",
"Name": "Gaming Mice",
"Description": "A high-performance low-latency light-wight mice for gaming.",
"Quantity": 22,
"Price": 68.99
},
{
"Id": "0196960d-9e4e-7585-9c74-86f60257d0e0",
"Name": "Dummy Product",
"Description": "This is a dummy product.",
"Quantity": 1,
"Price": 12.99
},
{
"Id": "ee909367-476a-431e-a80c-d720770df8e7",
"Name": "SK Hynix Internal SSD",
"Description": "SK Hynix Gold P31 1TB PCIe NVMe Gen3 M.2 2280 Internal SSD read up to 3500MB/s and write up to 3200MB/s.",
"Quantity": 1,
"Price": 119.99
},
{
"Id": "edc75d6b-b2c9-4d3a-83ca-3d9c81468521",
"Name": "Temp Product",
"Description": "This is a temp product",
"Quantity": 1,
"Price": 14.99
}
]π Returns a list of products using
ProductResponseDTO. Internal fields likeCreatedAtare excluded.
GET {{HostAddress}}/products/0196960d-9e4e-7585-9c74-86f60257d0e0
Content-Type: none
###{
"Id": "0196960d-9e4e-7585-9c74-86f60257d0e0",
"Name": "Dummy Product",
"Description": "This is a dummy product.",
"Quantity": 1,
"Price": 12.99
}π Returns a single product using
ProductResponseDTO. Internal fields likeCreatedAtare excluded.
POST {{HostAddress}}/products
Content-Type: application/json
{
"Name": "SK Hynix Internal SSD",
"Description": "SK Hynix Gold P31 1TB PCIe NVMe Gen3 M.2 2280 Internal SSD read up to 3500MB/s and write up to 3200MB/s.",
"Quantity": 1,
"Price": 119.99
}
###"ee909367-476a-431e-a80c-d720770df8e7"
π Consumes
CreateProductRequestDTO and returns newly generatedIdandLocationheader. Response body contains the Guid.
PUT {{HostAddress}}/products/
Content-Type: application/json
{
"id": "0196960d-4839-7971-8eb7-a37633230f2b",
"name": "Gaming Mice",
"description": "A high-performance low-latency light-wight mice for gaming.",
"quantity": 22,
"price": 68.99
}
###"Update Successful"
π Consumes
UpdateProductRequestDTO and returnsUpdate Successfulresponse as plain-text in case the update was successful. Minimal APIs wrap string responses in quotes ("...") withContent-Type: text/plain. Returns400 Bad Requestalong with a stringProduct Id is requiredif theIdis missing from request payload and returns404 Not Foundif the product with the specifiedIddoes not exist.
DELETE {{HostAddress}}/products/5c492ba0-3d44-402a-966e-56b578cf0648
Content-Type: none
###π Deletes the specified product by
Id. Returns204 No Contenton success, or404 Not Foundif the product with the specifiedIddoes not exist.
Letβs walk through how a client interacts with the API when creating or updating a product, tracing the request through mapping, persistence, and response delivery.
[1] π¨ Client sends HTTP request
βββ Example:
- GET /products
- GET products/0196960d-9e4e-7585-9c74-86f60257d0e0
- POST /products with JSON payload
{
"Name": "SK Hynix Internal SSD",
"Description": "SK Hynix Gold P31 1TB PCIe NVMe Gen3 M.2 2280 Internal SSD read up to 3500MB/s and write up to 3200MB/s.",
"Quantity": 1,
"Price": 119.99
}
- PUT /products with JSON payload
{
"id": "0196960d-4839-7971-8eb7-a37633230f2b",
"name": "Gaming Mice",
"description": "A high-performance low-latency light-wight mice for gaming.",
"quantity": 22,
"price": 68.99
}
β
The shape of the request is defined by CreateProductRequest or UpdateProductRequest DTO.
[2] π Minimal API endpoint receives the request
βββ Endpoint reads the DTO via [FromBody] binding
βββ Calls .ToEntity() extension method to transform DTO β Product entity
β
Explicit and manual mapping ensures control over data flow and avoids accidental exposure of internal fields.
[3] π§© Extension method maps DTO to entity
βββ For CreateProductRequest, CreatedAt is initialized inside the mapper
βββ For UpdateProductRequest, CreatedAt is preserved manually by reading the original entity from the database
β
Mapping logic lives in ProductDtoExtensions.cs to keep separation of concerns.
[4] ποΈ Repository processes the entity
βββ AddAsync() calls EF Coreβs .Add() and uses SaveChangesAsync()
βββ UpdateAsync() uses ExecuteUpdateAsync() for a direct SQL update with no change tracking
βββ DeleteAsync() uses ExecuteDeleteAsync() for efficient hard deletion
β
Repository abstracts persistence details via IProductRepository interface.
[5] ποΈ SQLite database is updated
βββ EF Core translates the entity into SQL statements
βββ Entity is written to Data\ProductDB.db
β
EF Core translates entity changes into SQL statements and persists them to the SQLite database.
[6] π€ Server returns appropriate response
βββ Might be:
- β
201 Created + Location header (on successful POST)
- β
"Update Successful" string (on successful PUT)
- β 400 Bad Request if payload Id is missing
- β 404 Not Found if requested resource is unavailable
- β
204 No Content if delete succeeds
β
All responses follow Minimal API conventions.
π§ This pipeline highlights how the application transforms transport-level DTOs into domain entities and then persists them safely ensuring clear contracts, safe updates, and intentional error handling.
While the demo emphasizes clarity and intentional design, here are some common mistakes to watch for when building production-grade APIs:
-
Over-mapping or redundant logic in endpoints
Avoid repeating transformation steps or placing mapping code inline with endpoint logic. Centralize it via extension methods to improve readability and maintenance.
-
Reusing entities directly in transport contracts
Domain entities often contain audit fields, tracking flags, or relationships that shouldn't be exposed externally. Use DTOs to shield internal structure.
-
Forgetting to handle null or default values in serializers
Configure JSON options explicitly to prevent unexpected behavior like serializing nulls or misaligned property casing with client expectations.
-
Reusing entities as DTOs
This leads to field leakage, especially for properties like
CreatedAtor EF navigation properties. Keep models intentionally scoped for their role. -
Skipping validation on client input
Without proper validation, incorrect or partial data can reach your database, leading to long-term inconsistency and business rule violations.
-
Overwriting entire entity during updates
Blindly replacing all fields can unintentionally erase important data. Always preserve audit fields (
CreatedAt) and consider partial updates where appropriate. -
Forgetting to name routes for resource creation
Named routes make it easier to generate Location URIs after
POSTrequests. Without them, you'll need to hardcode paths or skip confirmation headers. -
Assuming serialization defaults match frontend expectations
Contract alignment matters. Explicitly configure casing and null behavior so the frontend and backend remain predictable and interoperable.
π§ These pitfalls surface most often when layering complexity on top of simple APIs. Staying intentional with each design choice keeps your application safe, predictable, and easy to evolve.
This section outlines principles that promote clarity, safety, and maintainability when building Minimal API applications with manual DTO mapping.
-
Prefer explicit over automatic mapping
Manual mapping via extension methods ensures full control over how data moves between layers. While packages like
AutoMappercan reduce boilerplate, they introduce complexity that may not be justified in smaller projects.β Risks with automatic mapping:
- Relies on reflection and runtime analysis, which can affect performance, especially in high-throughput or startup-sensitive applications.
- Requires configuration to handle edge cases, increasing cognitive overhead.
- Convention-based mappings can silently include or exclude fields, making behavior less predictable and harder to debug.
β Why manual mapping excels:
- Behavior is transparent, expressive, and intentionally scoped.
- Mapping logic lives beside your domain types for easy discoverability.
- Eliminates surprises. Every field is transformed deliberately, reducing risk of unintended data exposure or contract mismatch.
Manual mapping aligns perfectly with small-to-mid sized APIs where clarity, control, and maintainability are more valuable than abstraction.
-
Separate transport contracts from domain models
DTOs (Data Transfer Objects) act as the shape of data exchanged between clients and servers. They are designed to capture just what's necessary for input (requests) or output (responses), shielding internal logic, persistence details, and sensitive metadata.
π‘οΈ Why separation matters:
- Domain entities often contain fields like
CreatedAt, audit flags, relationships, or EF Core navigation properties, which may be irrelevant, sensitive, or unstable across deployments. - Returning entities directly can leak internal implementation or cause serialization issues. For example, cyclic references or unexpected field exposure.
- Using entities as input models creates coupling to database structure, which increases the risk of invalid data manipulation, accidental overwrites, or unintended migrations.
β Benefits of using purpose-built DTOs:
- You control the surface area of the API contract explicitly.
- Changes to domain models donβt ripple into transport contracts.
- Requests stay lean, responses remain intentional, and versioning becomes easier over time.
- Mapping DTOs manually ensures domain logic stays protected and properly enriched before persistence.
This pattern reinforces architectural boundaries, treating your API as a curated interface, not a transparent mirror of your database schema.
- Domain entities often contain fields like
-
Preserve audit fields during updates
Audit fields (e.g.,
CreatedAt,ModifiedAt,CreatedBy) are typically assigned by the server to track lifecycle events of a record. When performing updates, especially with DTOs that represent only editable properties, it's essential to preserve these values to maintain historical accuracy and data integrity.β Common pitfalls:
- Overwriting the entire entity using
PUTorUpdateAsync()without rehydrating server-assigned fields. - Relying solely on DTO input which lacks fields like
CreatedAt, resulting in those being reset or lost.
β Recommended approach:
- Always fetch the original entity before applying updates.
- Copy over immutable fields (
CreatedAt) from the existing record manually. - Keep audit logic out of DTOs. Itβs a responsibility of the mapping layer or domain rules.
This safeguard ensures that each update maintains consistency with the original creation context which is especially critical in logging, compliance, or historical reporting scenarios.
- Overwriting the entire entity using
-
Centralize transformation logic in mapping methods
Mapping between DTOs and domain entities is a repetitive operation in most APIs. Centralizing this logic using dedicated extension methods or helper functions ensures consistency and reduces code duplication across endpoint handlers.
β Risks of inline or scattered mapping:
- Makes endpoint code noisy and harder to follow.
- Increases chances of subtle inconsistencies (e.g., forgetting to set a field or initializing timestamps incorrectly).
- Mapping logic becomes harder to test or reuse independently.
β Benefits of centralized mapping methods:
- Promotes separation of concerns, endpoints handle routing and orchestration, while mappers handle transformation.
- Encourages reuse, especially when DTOs are consumed across multiple endpoints.
- Supports cleaner unit tests. You can test mapping methods in isolation without spinning up the entire API.
- Makes debugging and enhancement easier. When domain changes occur, only the mappers need updating.
By keeping transformation logic in well-named methods (like
.ToEntity()or.ToDto()), your codebase remains expressive, maintainable, and resilient to change. All with minimal friction. -
Favor
TypedResultsfor consistent responsesTyped results in ASP.NET Coreβs Minimal APIs, like
TypedResults.Ok(...),TypedResults.Created(...), andTypedResults.NoContent()provide explicit, strongly-typed response contracts. They enhance discoverability through IntelliSense, reduce ambiguity, and reinforce semantic correctness across your endpoints.β Risks of returning anonymous or untyped results:
- Potential for inconsistent response formats across endpoints.
- Harder to trace and refactor when business logic grows.
- Ambiguous return values (e.g., raw strings or tuples) can cause confusion downstream or when generating OpenAPI metadata.
β Why typed results matter:
- Encourage precise pairing of status codes with content. For example,
Created(...)always implies201and can include a Location header. - Improve IDE tooling support, surfacing available response types as suggestions during development.
- Reduce accidental mismatches between payloads and status codes (e.g., returning JSON with
NoContent). - Make unit testing and mocking easier by exposing response types explicitly.
Typed results offer both clarity and intent. When combined with purpose-built DTOs and meaningful status codes, they help create APIs that are not just functional but predictable, educational, and easy to integrate.
-
Design endpoints with clear route grouping and names (recommended design choice)
Using
MapGroup()to group related routes under a common prefix (/products,/orders, etc.) improves organization and helps developers navigate endpoint definitions quickly. Naming routes with.WithName()is optional but beneficial when you need to generate URLs dynamically such as in aCreated(...)response for aPOSTrequest.π οΈ When is it optional?
- For small internal APIs or prototypes, explicit names and grouping may feel verbose.
- If youβre not generating URIs or using reverse routing,
.WithName()might be skipped safely.
β Benefits of this approach:
- Keeps related routes visually and structurally connected.
- Makes your routing logic modular and easier to extend.
- Allows precise URI generation using
LinkGenerator.GetPathByName(...),LinkGenerator.GetUriByName(...), or tag helpers. - Enables reverse routing without hardcoded paths especially useful when returning
Locationheaders.
This pattern strikes a balance between structure and flexibility. It's not required, but itβs one of those small practices that pays dividends in readability and long-term usability.
-
Configure JSON serialization consciously
ASP.NET Core provides a highly customizable JSON serialization pipeline via
JsonOptions, powered bySystem.Text.Json, offering fine-grained control over how JSON is shaped and interpreted. The current configuration which sets casing policies, null handling, reference management, and case insensitivity forms an excellent baseline for predictable contracts.You can configure these options for Controllers using
AddControllers()inProgram.cs.builder.Services.AddControllers().AddJsonOptions(options => { // Configure JSON serializer to ignore null values during serialization options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; // Configure JSON serializer to use Pascal case for property names during serialization options.JsonSerializerOptions.PropertyNamingPolicy = null; // Configure JSON serializer to use Pascal case for key's name during serialization options.JsonSerializerOptions.DictionaryKeyPolicy = null; // Ensure JSON property names are not case-sensitive during deserialization options.JsonSerializerOptions.PropertyNameCaseInsensitive = true; // Prevent serialization issues caused by cyclic relationships in EF Core entities options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; // Ensure the JSON output is consistently formatted for readability. // Not to be used in Production as the response message size could be large // options.JsonSerializerOptions.WriteIndented = true; });
β Key configurations in this demo:
- Pascal casing preservation via
PropertyNamingPolicy = nullandDictionaryKeyPolicy = null. - Null value suppression using
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull. - Cycle avoidance through
ReferenceHandler.IgnoreCycles. - Case-insensitive deserialization with
PropertyNameCaseInsensitive = true.
π Additional configuration options to consider as APIs grow:
WriteIndented = true(for pretty-printing during development or debugging).- Custom converters (e.g., for enums,
DateTime, or polymorphic types). - Control over number formatting and precision (e.g., decimal rounding).
- Default value handling and conditional serialization.
- Support for camelCase when integrating with JavaScript-heavy clients.
β Risks of relying on defaults:
- Clients may experience unexpected casing or missing fields.
- Circular references can cause runtime errors if not handled early.
- Lack of configuration can limit contract clarity and increase onboarding friction.
Making your intentions explicit in
Program.csstrengthens communication between backend and frontend teams and keeps your contract behavior discoverable. - Pascal casing preservation via
-
Validate client input before persistence
While this demo intentionally skips full-fledged input validation to keep the focus on DTO mapping and endpoint structure, validation remains an essential step in production scenarios.
β Simple checks worth adding later:
- Ensure identifiers (e.g.,
Guid.Id) are not empty. - Confirm required fields are present and logically valid.
- Block negative or out-of-range values that violate business rules.
π‘οΈ Why it matters:
- Prevents incorrect or incomplete data from reaching the database.
- Enables early error feedback to clients, improving usability.
- Helps enforce business rules and maintain long-term data integrity.
- Ensure identifiers (e.g.,
-
Use NoTracking on read queries
When retrieving data that wonβt be updated during the current request, using
AsNoTracking()tells Entity Framework Core not to track changes to the returned entities. This reduces memory usage, avoids unnecessary change detection, and improves overall query performance.β Risks of not using it:
- Unintended database updates if modified entities are flushed with
SaveChangesAsync(). - Larger memory footprint, especially with complex graphs or high-volume queries.
- Reduced query performance in read-heavy endpoints.
β Why this is beneficial:
- EF Core skips building the change tracker graph, lowering CPU and memory overhead.
- Eliminates accidental updates. Data is treated as read-only, so even if modified in memory, it wonβt be persisted unless explicitly reattached.
- Ideal for endpoints like
GET /productsorGET /products/{id}, where the response is strictly informational.
π§ When to avoid
AsNoTracking():- If you plan to modify and persist the entity within the same request.
- When lazy loading or navigation properties require tracking behavior.
Using
AsNoTracking()isnβt just an optimization, it reinforces the intent of βread-onlyβ access, making your API behavior cleaner and safer by design. - Unintended database updates if modified entities are flushed with
-
Document expected request/response shapes
An API isnβt just a functional interface, itβs a contract between backend and client. By documenting sample requests and responses clearly, you help consumers understand the shape, expectations, and flow of data before they even touch the code.
β Why this matters:
- Reduces guesswork for frontend developers and third-party integrators.
- Clarifies which fields are required, what output looks like, and how to structure calls.
- Accelerates onboarding and testing by giving real-world examples that can be run or adapted instantly.
π How this demo supports it:
- Includes realistic request and response examples for each endpoint using
.httpfile and inline samples. - Demonstrates actual
GuidIDs, DTO structures, and returned payloads. - Uses commentary to explain status codes, formats, and Minimal API conventions (e.g., why string responses are wrapped in quotes).
Well-crafted samples turn documentation from reference material into an invitation to explore and this project showcases that with precision.
π§ Following these practices ensures your Minimal APIs remain easy to reason about, safe for consumers, and ready for growth as complexity increases.
These resources provide official guidance and additional context behind the concepts demonstrated in this project:
-
Minimal APIs in ASP.NET Core Overview of Minimal API routing, endpoint design, and response conventions.
-
Using Extension Methods in C# Language feature that powers manual mapping between DTOs and entities.
-
Entity Framework Core Documentation Covers repository setup,
AsNoTracking, and database operations via EF Core. -
System.Text.Json Configuration Reference for JSON serializer settings including casing, null handling, and reference behavior.
-
OpenAPI Integration in ASP.NET Core Setup and configuration tips for enabling API documentation using Swashbuckle.
π These links support the architectural clarity emphasized in this demo and offer next steps for deeper exploration.
π§ Stay Curious. Build Thoughtfully.