A simple patient lookup might not sound exciting, but it is a perfect chance to show how clean code and solid architecture make all the difference. This example uses ASP.NET Core and stored procedures to do just that. While the use case is straightforward, the architecture adheres to the SOLID principles, maintains separation of concerns, and follows clean code and enterprise-level structuring. This makes the project a practical example of how even simple features benefit from strong software design.
Software development is not only about delivering functionality it is about delivering maintainable, scalable, and testable solutions. Even in small applications or single-feature projects like a patient lookup, poor design decisions can lead to tight coupling, duplication, and fragile code that is difficult to evolve.
The goal is to try and demonstrate how to take a simple use case retrieving a patient by ID from a SQL Server database and wrap it in a SOLID-compliant architecture using ASP.NET Core. The goal is to illustrate how enterprise-quality design is not just for complex systems. It brings clarity, reliability, and extensibility to any codebase, regardless of size.
By isolating responsibilities into Models, Repositories, Services, and Web UI layers, you can ensure the system remains:
- Easy to understand and reason about
- Simple to test and mock
- Safe to extend or refactor
- Well-suited to real-world business logic growth
Even if the system starts small, this approach sets a strong foundation that avoids rewrites and future technical debt. It is not about overengineering, but about applying clean architectural principles from the beginning when they are cheapest and most effective to implement.
Goals
- Clean separation of concerns using N-Tier Architecture
- Full adherence to the SOLID principles
- Use of Dependency Injection, async patterns, and stored procedures
- A minimal but functional Web API and Razor Page frontend
- Centralized configuration and clean routing
- A project structure that scales with growth
Recommended Solution Structure
This solution is organized into the following projects:
PatientLookupSolution
├── PatientLookup.Models // Plain old class object
├── PatientLookup.Repositories // SQL logic and ADO.NET (SqlDataClient)
├── PatientLookup.Services // Business logic
└── PatientLookup.Web // API and Razor UI
Each layer only depends on the one below it:
- Web depends on Services
- Services depends on Repositories
- Repositories depends on Models This architecture ensures modularity, testability, and scalability.
SOLID Breakdown
Single Responsibility: Each class serves a single purpose: controller, service, repository, etc.
Open/Closed: You can extend behaviors (e.g., new services) without modifying existing code.
Liskov Substitution: Interfaces are respected; substituting mock services or test repositories is safe.
Interface Segregation: Interfaces are focused (e.g., IPatientService only exposes needed methods).
Dependency Inversion: Higher layers depend on abstractions (IPatientRepository), not implementations.
Code Walkthrough
Patient.cs (Model Layer)
namespace PatientLookup.Models
{
public class Patient
{
public int Id { get; set; }
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
}
}
- Just a normal class, no attributes describing infrastructure concerns or other responsibilities
- No data annotations to keep the model agnostic of UI or DB layers.
IPatientRepository
This is the part of the application responsible for talking directly to the database in a controlled and consistent way. It defines the contract for how the rest of the system can ask for patient data without needing to know how that data is actually retrieved.
public interface IPatientRepository
{
Task<Patient?> GetByIdAsync(int id);
}
Basically it tells the system: "If you give me a patientId, I will return the matching Patient object asynchronously."
But it does not say how it does that. The actual logic of running the stored procedure and mapping the SQL result to a model is handled in the class that implements the IPatientRepository public interface (PatientRepository).
IPatientRepository defines a clear boundary between the application and the data layer. It is a small piece with a big impact when it comes to keeping the code clean, testable, and future-proof.
PatientRepository (Repository Layer)
PatientRepository is the implementation of IPatientRepository. It contains the actual logic for accessing the database by calling a stored procedure to fetch patient data. It focuses only on fetching data. No business logic. No UI code. This keeps the data layer clean, testable, and easy to change without breaking the rest of the system.
public class PatientRepository : IPatientRepository
{
private readonly string _connectionString;
public PatientRepository(IConfiguration config)
{
_connectionString = config.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("Missing DefaultConnection string.");
}
public async Task<Patient?> GetByIdAsync(int id)
{
using var conn = new SqlConnection(_connectionString);
using var cmd = new SqlCommand("cp_GetPatientById", conn)
{
CommandType = CommandType.StoredProcedure
};
cmd.Parameters.AddWithValue("@patid", id);
await conn.OpenAsync();
using var reader = await cmd.ExecuteReaderAsync();
if (await reader.ReadAsync())
{
return new Patient
{
Id = reader.GetInt32(reader.GetOrdinal("Id")),
FirstName = reader.GetString(reader.GetOrdinal("FirstName")),
LastName = reader.GetString(reader.GetOrdinal("LastName"))
};
}
return null;
}
}
- Uses ADO.NET for performance and transparency.
- Clean separation from business logic.
- Uses a stored procedure for optimized and secure DB access.
IPatientService
IPatientService defines the business-facing contract for how the application can retrieve patient information. It sits above the repository and lets higher layers (like the controller) request patient data without knowing anything about the data source. This abstraction makes the service layer testable, swappable, and cleanly separated from the database layer.
public interface IPatientService
{
Task<Patient?> GetPatientByIdAsync(int id);
}
- Acts as the middleman between the controller and the data layer, handling patient-related business logic.
- Offers a simple async method "GetPatientByIdAsync" to fetch a patient by their ID.
PatientService (Service Layer)
PatientService is the implementation of IPatientService. It calls the repository to fetch data and can apply business rules, validation, logging, or transformations before returning the result. Basically, it acts as a bridge between the controller and the repository cleanly separating concerns and centralizing any logic beyond raw data access.
public class PatientService : IPatientService
{
private readonly IPatientRepository _repository;
public PatientService(IPatientRepository repository)
{
_repository = repository;
}
public Task<Patient?> GetPatientByIdAsync(int id)
{
return _repository.GetByIdAsync(id);
}
}
PatientController (Web API Layer)
PatientController is the entry point of the application. It handles incoming HTTP requests, delegates work to the service layer, and returns appropriate responses (usually JSON or a view). In this case, it returns the response to a view.
[Route("api/[controller]")]
[ApiController]
public class PatientController : ControllerBase
{
private readonly IPatientService _service;
public PatientController(IPatientService service)
{
_service = service;
}
[HttpGet("{id}")]
public async Task<IActionResult> GetPatient(int id)
{
var patient = await _service.GetPatientByIdAsync(id);
if (patient == null)
return NotFound();
return Ok(patient);
}
}
- Validates incoming requests at a high level (e.g., route-level).
- Delegates the actual logic to the service layer.
- Returns a clean HTTP response (200 OK, 404 Not Found, etc.).
- It does not contain business or data logic.
- Minimal and clean controller.
- Relies only on the service interface.
Razor Page: PatientView.cshtml
@page
@model PatientLookup.Web.Pages.PatientViewModel
@{
ViewData["Title"] = "Patient Details";
}
<h2>Patient Details</h2>
@if (Model.Patient != null)
{
<div>
<strong>ID:</strong> @Model.Patient.Id<br />
<strong>Name:</strong> @Model.Patient.FirstName @Model.Patient.LastName
</div>
}
else
{
<div>Patient not found.</div>
}
Code-Behind: PatientView.cshtml
public class PatientViewModel : PageModel
{
private readonly IPatientService _service;
public PatientViewModel(IPatientService service)
{
_service = service;
}
[BindProperty(SupportsGet = true)]
public int Id { get; set; }
public Patient? Patient { get; set; }
public async Task<IActionResult> OnGetAsync()
{
Patient = await _service.GetPatientByIdAsync(Id);
return Patient == null ? NotFound() : Page();
}
}
- Clean and simple frontend view.
- Demonstrates reuse of business logic via dependency injection in Razor Pages.
Program.cs
Program.cs is the glue that connects and configures all the architectural layers at startup. It is the application entry point in ASP.NET Core and configures the web host, registers services for dependency injection, defines how the application will run, etc.
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using PatientLookup.Repositories;
using PatientLookup.Services;
using System.Data;
using System.Data.SqlClient;
var builder = WebApplication.CreateBuilder(args);
// Load configuration
builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
// Register controllers and framework services
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Register custom services and repositories
builder.Services.AddScoped<IPatientRepository, PatientRepository>();
builder.Services.AddScoped<IPatientService, PatientService>();
// Register SQL connection for dependency injection
builder.Services.AddScoped<IDbConnection>(sp =>
new SqlConnection(builder.Configuration.GetConnectionString("DefaultConnection")));
var app = builder.Build();
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
appsettings.json (environment based configuration)
{
"ConnectionStrings": {
"DefaultConnection": "Server=SQLServer;Database=YourDb;Trusted_Connection=True;"
}
}
Best Practice Highlights
- Use Stored Procedures: More secure, better for performance.
- Separation of Concerns: Data, business, and presentation logic are isolated.
- Thin Controllers: Controllers delegate logic to services.
- Interface-Driven Design: Easily testable and extensible.
- Async Everywhere: Improves scalability.
How all the Layers Work Together
This architecture separates responsibilities across four key layers, each with a clear purpose:
Controller Layer
Responsibility: Handles HTTP requests and returns responses.
Example: PatientController
Accepts GET /api/patient/123
Calls IPatientService.GetPatientAsync(123)
Returns 200 OK with the patient object or 404 Not FoundService Layer
Responsibility: Contains business logic and orchestrates data access.
Example: PatientService
Validates input (e.g., patientId > 0)
Calls repository: IPatientRepository.GetPatientByIdAsync()
Applies any business rules before returning dataRepository Layer
Responsibility: Communicates with the database (via stored procedures or queries).
Example: PatientRepository
Executes cp_APIGetPatientById
Maps SqlDataReader result to a Patient model
Returns the data to the service layerModel Layer
Responsibility: Defines the shape of your data objects.
Example: Patient class
Represents patient data across layers
Keeps your code strongly typed and structured
Separation of Concerns: Each layer does one thing and does it well
Testability: You can mock or stub any layer during unit testing
Maintainability: Changes in one layer (e.g., database schema) do not break the rest
Scalability: Easily extend logic (e.g., add caching or logging) without disrupting the design
Conclusion
This example illustrates how clean, scalable architecture is not limited to large enterprise projects. By applying the SOLID principles, structuring with layered boundaries, and embracing clean code practices, you create software that is:
- Easy to maintain
- Easy to extend
- Easier to onboard new developers
- Ready for scaling into production
Top comments (1)
Thanks to explain, is very helpful!