This document outlines the structured development of an API designed to efficiently manage fundamental business entities through standardized CRUD (Create, Read, Update, Delete) operations. This API serves as a robust interface for handling Products
, Categories
, Customers
, and Suppliers
, ensuring seamless interaction with the underlying database.
Each entity plays a vital role within the application, necessitating well-defined data models and endpoints to facilitate operations such as record creation, retrieval, modification, and deletion.
Project Structure
To ensure modularity, maintainability, and scalability, the API project should be organized into well-defined folders, each serving a distinct purpose:
Entities/ # Models representing database entities, with properties mapped to table columns
DTOs/ # Data Transfer Objects for request/response handling
Repositories/ # Data access logic, separated from business logic
Services/ # Business logic, managing interactions between repositories and controllers
Controllers/Endpoints/ # API endpoints for handling HTTP requests and responses
Validators/ # Validation logic, ensuring data integrity before processing requests
Middleware/ # Logic that processes requests/responses at various pipeline stages (e.g., authentication, logging)
Entity Classes
The Entities
folder should encapsulate classes representing each key entity in the database, defining properties that correspond to table columns. The DeletedOn
and DeletedBy
columns are included to facilitate filtering of deleted records, as the records are retained in the database rather than being permanently removed.
Example
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 CreatedOn { get; set; }
public string CreatedBy { get; set; } = string.Empty;
public DateTime? DeletedOn { get; set; }
public string? DeletedBy { get; set; }
}
public class Customer
{
public Guid Id { get; set; }
public string CompanyName { get; set; } = string.Empty;
public string ContactName { get; set; } = string.Empty;
public string ContactTitle { get; set; } = string.Empty;
public string Address { get; set; } = string.Empty;
public string? Region { get; set; } = string.Empty;
public string PostalCode { get; set; } = string.Empty;
public string Country { get; set; } = string.Empty;
public string Phone { get; set; } = string.Empty;
public string? Fax { get; set; } = string.Empty;
public DateTime CreatedOn { get; set; }
public string CreatedBy { get; set; } = string.Empty;
public DateTime? DeletedOn { get; set; }
public string? DeletedBy { get; set; }
}
Utilizing a Base Entity Class
Since multiple entities share common properties such as CreatedOn
, CreatedBy
, DeletedOn
, and DeletedBy
, it is advisable to define a base entity class that serves as a foundational structure for inheritance.
Base Entity Class Example
public abstract class BaseDomain
{
public DateTime CreatedOn { get; set; }
public string CreatedBy { get; set; } = string.Empty;
public DateTime? DeletedOn { get; set; }
public string? DeletedBy { get; set; } = string.Empty;
}
By inheriting from BaseDomain
, entities can reuse shared properties, enhancing consistency and reducing redundancy.
public class Product : BaseDomain
{
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 class Customer : BaseDomain
{
public Guid Id { get; set; }
public string CompanyName { get; set; } = string.Empty;
public string ContactName { get; set; } = string.Empty;
public string ContactTitle { get; set; } = string.Empty;
public string Address { get; set; } = string.Empty;
public string? Region { get; set; } = string.Empty;
public string PostalCode { get; set; } = string.Empty;
public string Country { get; set; } = string.Empty;
public string Phone { get; set; } = string.Empty;
public string? Fax { get; set; } = string.Empty;
}
DbContext Example
Entity classes serve as the foundation for table creation within Entity Framework, facilitating seamless mapping between code and database structures.
public class AppDbContext : DbContext
{
public DbSet<Product> Products { get; set; }
public DbSet<Category> Categories { get; set; }
public DbSet<Customer> Customers { get; set; }
public DbSet<Supplier> Suppliers { get; set; }
}
Structuring DTO's
What is a DTO?
A Data Transfer Object (DTO) is a simple object used to transfer data between layers of an application. DTOs help keep your API contracts clean and separate from your database models, ensuring that only the necessary data is exposed to clients and that internal implementation details remain hidden.
The API project should feature a DTOs
folder designated for defining Data Transfer Objects (DTOs), which facilitate structured data exchange between clients and the server. Common examples of naming DTOs are CreateProductRequest
, UpdateProductRequest
, ProductResponse
, etc. Each is tailored to meet distinct data processing needs within the API.
Best Practices:
- Declare DTO classes as
sealed
to prevent inheritance. - Avoid third-party NuGet packages for automatic mapping from entities to DTOs and vice versa. Prefer manual mapping for greater control.
- Utilize data annotation attributes for validation if required.
Rules for Defining DTO's
When designing DTOs, adhere to the following principles:
- DTOs must not contain logic or behavior.
- DTOs should not enforce encapsulation. Private or protected members are unnecessary.
- Properties (not fields) must be used for serialization compatibility.
- Use
DTO
in naming only when necessary. Prefer meaningful names based on usage. - Mark DTO classes/records as
sealed
when inheritance is not required. - DTOs are best suited for:
- API Request or Response objects.
- Messaging (Commands, Events, Queries, etc.).
DTO Examples
public sealed class CreateProductRequest
{
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 sealed class UpdateProductRequest
{
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 sealed class ProductResponse
{
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; }
}
DTO Validation
Ensuring data integrity is a crucial aspect of API development. Proper validation mechanisms help prevent erroneous or malicious data from entering the system, improving reliability and security. Below are some recommended validation approaches for handling API requests effectively.
Built-in Validation (Validator.TryValidateObject)
The .NET framework provides built-in validation capabilities through data annotation attributes, which can be processed using the Validator.TryValidateObject() method. This approach allows developers to validate DTOs by checking for constraints such as required fields, length restrictions, and format enforcement.
Example...
public class CreateProductRequest
{
[Required]
[StringLength (100)]
public string Name { get; set; } = string.Empty;
[StringLength (500)]
public string Description { get; set; } = string.Empty;
[Range (1, int.MaxValue)]
public int Quantity { get; set; }
[Range (0.01, double.MaxValue)]
public decimal Price { get; set; }
}
// Validation Execution - productRequest is an endpoint argument for accepting user input
var validationResults = new List<ValidationResult> ();
var context = new ValidationContext (productRequest);
bool isValid = Validator.TryValidateObject (productRequest, context, validationResults, true);
This method provides a simple and effective way to enforce constraints without relying on additional libraries.
Third-Party Validation (FluentValidation)
FluentValidation is a widely used third-party NuGet package that provides a flexible and expressive way to define validation rules. Unlike attribute-based validation, FluentValidation offers a programmatic approach to defining conditions.
Example...
public class CreateProductValidator : AbstractValidator<CreateProductRequest>
{
public CreateProductValidator()
{
RuleFor (x => x.Name)
.NotEmpty ().WithMessage ("Product name must not be empty.")
.MaximumLength (100).WithMessage ("Product name must not be greater than 100 characters.");
RuleFor (x => x.Description)
.MaximumLength (500);
RuleFor (x => x.Quantity)
.GreaterThan (0);
RuleFor (x => x.Price)
.GreaterThan (0);
}
}
// In Asp Net Core application, one would rather inject the IValidator<CreateProductRequest> either in the middleware, or endpoint/controller.
// Instead of creating the instance, the value will come as an argument to the endpoint/controller method.
CreateProductRequest request = new()
{
Name = "Gaming Laptop",
Description = "A high-performance laptop with advanced graphics for gaming.",
Quantity = 69,
Price = 999.99
};
CreateProductValidator validator = new();
var validationResult = await validator.ValidateAsync (request);
if (!validationResult.IsValid)
{
foreach (var validationError in validationResult.Errors)
{
WriteLine ($"Property '{validationError.PropertyName}' failed validation. " +
$"Error: { validationError.ErrorMessage}");
}
}
else
{
WriteLine ("Validation Passed!");
}
Best Practices for FluentValidation:
- Define validation rules in separate classes to ensure clean separation of concerns.
- Register validators in the Dependency Injection (DI) container to enable automatic validation at runtime.
- Avoid excessive validation complexity within validator classes. Keep rules simple and maintainable.
Middleware Validation
For broader request validation, implementing validation logic within middleware ensures that data is processed before reaching controllers, improving efficiency and reducing redundancy.
Example...
Middleware can intercept incoming requests and check for validation errors before executing business logic.
app.Use (async (context, next) =>
{
var errors = ValidateRequest (context);
if (errors.Any ())
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
await context.Response.WriteAsync (JsonConvert.SerializeObject(errors));
return;
}
await next (context);
});
private List<string> ValidateRequest(HttpContext context)
{
var errors = new List<string>();
// Try to parse the request body into CreateProductRequest object
context.Request.EnableBuffering();
using (var reader = new StreamReader(context.Request.Body, Encoding.UTF8, leaveOpen: true))
var body = reader.ReadToEnd();
context.Request.Body.Position = 0;
if (!string.IsNullOrEmpty(body))
{
try
{
var request = JsonConvert.DeserializeObject<CreateProductRequest>(body);
if (request is not null)
{
var validationContext = new ValidationContext(request);
var validationResults = new List<ValidationResult>();
if (!Validator.TryValidateObject(request, validationContext, validationResults, true))
{
errors.AddRange(validationResults.Select(x => x.ErrorMessage));
}
}
else
errors.Add("Invalid request payload.");
}
catch (Exception)
{
errors.Add("Error parsing request body.");
}
}
else
errors.Add("Request body cannot be empty.");
return errors;
}
Middleware-based validation is particularly useful for enforcing global request policies, such as checking authentication tokens, request size limits, or content-type validation.
Controller/Endpoint Validation
Direct validation within controller actions ensures that each incoming request adheres to expected constraints before execution.
Example...
[HttpPost]
public IActionResult CreateProduct([FromBody] CreateProductRequest request)
{
if (!ModelState.IsValid)
return BadRequest(ModelState);
// Proceed with request processing (e.g., saving to database)
return Ok(new { Message = "Product created successfully!" });
}
While this approach is effective, placing validation at the endpoint level can lead to repetitive logic across multiple controllers. Consider using filters or middleware for a more centralized validation mechanism.
Choosing the Right Validation Strategy
Selecting the appropriate validation approach depends on the complexity of your application and specific requirements.
Validation Approach | Use Case | Complexity |
---|---|---|
Built-in Validation | Simple DTO validation with attributes | Low |
Fluent Validation | Customizable and reusable validation rules | Moderate |
Middleware | Global validation before request execution | High |
Endpoint | Specific request validation at the controller | Moderate |
Combining multiple validation methods ensures robustness while maintaining modularity. Use built-in validation for basic constraints, FluentValidation for complex validation logic, and middleware for handling global rules efficiently.
Repository Pattern (Optional)
The repository pattern is a popular way to abstract data access logic and improve testability. However, for simple CRUD applications, it can introduce unnecessary complexity. Consider using the repository pattern when:
- You need to support multiple data sources
- You want to add custom data access logic
- You need to improve testability for complex business logic
For small or straightforward projects, direct use of DbContext
is often sufficient and easier for beginners.
Conclusion
This architecture provides a well-structured, scalable, and maintainable approach to API development. By following best practices in entity modeling, DTO structuring, and validation strategies, developers can build robust APIs that ensure data integrity and high performance.
Key Takeaways
- Organize your project for clarity and maintainability.
- Use DTOs to separate API contracts from internal models.
- Choose validation strategies that fit your application's complexity.
- Apply the repository pattern only when it adds value.
Further Reading
- Microsoft Docs: Data Transfer Objects (DTOs)
- Microsoft Docs: Validation in ASP.NET Core
- Repository Pattern Guidance
- FluentValidation Documentation
About the Author
Hi, I’m Jiten Shahani
, a passionate developer with a strong background in API development and C# programming. Although I’m new to .NET, my journey into learning ASP .NET Core began in December 2024, driven by a desire to build scalable and maintainable applications.
Feel free to connect with me to exchange ideas and learn together!
Top comments (0)