Refactoring to the Repository pattern
Up to this point, we haven't really discussed software patterns (also known as design patterns) - e.g. common strategies you can use to create useful abstractions. You've probably seen them mentioned at some point.
But why haven't we mentioned them yet?
🌶️🌶️🌶️ Mainly because Spencer doesn't speak in software patterns much. They're an object of fascination for some developers, but there are only really a few useful ones that you'll need to know for this course and for 90% of the software you'll write.
Pattern | Description |
---|---|
Repository | A pattern that allows you to separate the concerns of data storage from the concerns of data access. |
Factory | A pattern that allows you to separate the concerns of object creation from the concerns of object usage. A fancy name for an object that is responsible for creating other objects. |
Unit of Work | A pattern that allows you to group multiple operations into a single unit of work. |
We'll discuss these more as they come up in the course. Don't worry, plenty of spicy takes inbound.
🌶️🌶️🌶️ I don't want you to think that Spencer doesn't think design patterns are important. They are in fact important, and integrated into many of the languages and frameworks you'll use. That said, he likes to focus on doing first and letting the complexity of the architecture reveal itself such that you can then go back and refactor into these relevant patterns.
Let's start by talking about the Repository pattern.
What is the Repository pattern?
The Repository pattern is a design pattern that allows you to separate the concerns of data storage (e.g. a database) from the concerns of data access.
It's used quite a bit in real-world ASP.NET Core applications, and in that way it's important to understand.
Let's focus in our code first and foremost, specifically this line. This has been our "repo" up to this point:
var employees = new List<Employee>
{
new Employee { Id = 1, FirstName = "John", LastName = "Doe" },
new Employee { Id = 2, FirstName = "Jane", LastName = "Doe" }
};
This is SO not how it's done in the real world.
- For one, this list is not persisted anywhere - as soon as the application shuts down, the data is destroyed. In the real world, we rely heavily on databases to persist our data.
- Further, the methods of
List<Employee>
don't model the well the operations we actually want to perform.
That said, we may want our tests to be able to use a separate data source so we can test the behavior of the API without relying on a real database. Or we want a common way to access data from different parts of the application.
So we'll introduce a new generic interface that will represent our generic Repository. Let's add this to a folder called Abstractions
for now.
public interface IRepository<T>
{
T? GetById(int id);
IEnumerable<T> GetAll();
void Create(T entity);
void Update(T entity);
void Delete(T entity);
}
The longest journey begins with a single step, so let's start by abstracting our List<Employee>
into this repository. We'll call it EmployeeRepository
and put it into the Employees
folder.
public class EmployeeRepository : IRepository<Employee>
{
private readonly List<Employee> _employees = new();
public Employee? GetById(int id)
{
return _employees.FirstOrDefault(e => e.Id == id);
}
public IEnumerable<Employee> GetAll()
{
return _employees;
}
public void Create(Employee entity)
{
if (entity == null)
{
throw new ArgumentNullException(nameof(entity));
}
//we snuck this in because we're no longer providing default employees!
entity.Id = _employees.Select(e => e.Id).DefaultIfEmpty(0).Max() + 1;
_employees.Add(entity);
}
public void Update(Employee entity)
{
if (entity == null)
{
throw new ArgumentNullException(nameof(entity));
}
var existingEmployee = GetById(entity.Id);
if (existingEmployee != null)
{
existingEmployee.FirstName = entity.FirstName;
existingEmployee.LastName = entity.LastName;
}
}
public void Delete(Employee entity)
{
if (entity == null)
{
throw new ArgumentNullException(nameof(entity));
}
_employees.Remove(entity);
}
}
We'll need to also update our endpoints to use this new repository. We can do that by adding the EmployeeRepository
to the function signature of each of our methods and refactoring the rest of the code to use the repository.
employeeRoute.MapGet(string.Empty, (EmployeeRepository repository) => {
return Results.Ok(repository.GetAll().Select(employee => new GetEmployeeResponse {
FirstName = employee.FirstName,
LastName = employee.LastName,
Address1 = employee.Address1,
Address2 = employee.Address2,
City = employee.City,
State = employee.State,
ZipCode = employee.ZipCode,
PhoneNumber = employee.PhoneNumber,
Email = employee.Email
}));
});
employeeRoute.MapGet("{id:int}", (int id, EmployeeRepository repository) => {
var employee = repository.GetById(id);
if (employee == null)
{
return Results.NotFound();
}
return Results.Ok(new GetEmployeeResponse {
FirstName = employee.FirstName,
LastName = employee.LastName,
Address1 = employee.Address1,
Address2 = employee.Address2,
City = employee.City,
State = employee.State,
ZipCode = employee.ZipCode,
PhoneNumber = employee.PhoneNumber,
Email = employee.Email
});
});
employeeRoute.MapPost(string.Empty, (CreateEmployeeRequest employeeRequest, EmployeeRepository repository) => {
var newEmployee = new Employee {
FirstName = employeeRequest.FirstName,
LastName = employeeRequest.LastName,
SocialSecurityNumber = employeeRequest.SocialSecurityNumber,
Address1 = employeeRequest.Address1,
Address2 = employeeRequest.Address2,
City = employeeRequest.City,
State = employeeRequest.State,
ZipCode = employeeRequest.ZipCode,
PhoneNumber = employeeRequest.PhoneNumber,
Email = employeeRequest.Email
};
repository.Create(newEmployee);
return Results.Created($"/employees/{newEmployee.Id}", employeeRequest);
});
employeeRoute.MapPut("{id}", (UpdateEmployeeRequest employeeRequest, int id, EmployeeRepository repository) => {
var existingEmployee = repository.GetById(id);
if (existingEmployee == null)
{
return Results.NotFound();
}
existingEmployee.Address1 = employeeRequest.Address1;
existingEmployee.Address2 = employeeRequest.Address2;
existingEmployee.City = employeeRequest.City;
existingEmployee.State = employeeRequest.State;
existingEmployee.ZipCode = employeeRequest.ZipCode;
existingEmployee.PhoneNumber = employeeRequest.PhoneNumber;
existingEmployee.Email = employeeRequest.Email;
repository.Update(existingEmployee);
return Results.Ok(existingEmployee);
});
We can then run our tests and see that... wait what?!
GetAllEmployees_ReturnsOkResult [FAIL]
Failed TheEmployeeAPI.Tests.BasicTests.CreateEmployee_ReturnsBadRequestResult [179 ms]
Error Message:
Assert.Equal() Failure: Values differ
Expected: BadRequest
Actual: InternalServerError
Stack Trace:
at TheEmployeeAPI.Tests.BasicTests.CreateEmployee_ReturnsBadRequestResult() in /Users/spencer/Repos/building-apis-with-csharp-and-aspnet-core-code/2-F-adding-validation-to-your-api/TheEmployeeAPI.Tests/UnitTest1.cs:line 49
--- End of stack trace from previous location ---
Failed TheEmployeeAPI.Tests.BasicTests.CreateEmployee_ReturnsCreatedResult [6 ms]
Error Message:
System.Net.Http.HttpRequestException : Response status code does not indicate success: 500 (Internal Server Error).
Stack Trace:
at System.Net.Http.HttpResponseMessage.EnsureSuccessStatusCode()
at TheEmployeeAPI.Tests.BasicTests.CreateEmployee_ReturnsCreatedResult() in /Users/spencer/Repos/building-apis-with-csharp-and-aspnet-core-code/2-F-adding-validation-to-your-api/TheEmployeeAPI.Tests/UnitTest1.cs:line 40
--- End of stack trace from previous location ---
Failed TheEmployeeAPI.Tests.BasicTests.GetEmployeeById_ReturnsOkResult [1 ms]
Error Message:
System.Net.Http.HttpRequestException : Response status code does not indicate success: 500 (Internal Server Error).
Stack Trace:
at System.Net.Http.HttpResponseMessage.EnsureSuccessStatusCode()
at TheEmployeeAPI.Tests.BasicTests.GetEmployeeById_ReturnsOkResult() in /Users/spencer/Repos/building-apis-with-csharp-and-aspnet-core-code/2-F-adding-validation-to-your-api/TheEmployeeAPI.Tests/UnitTest1.cs:line 31
--- End of stack trace from previous location ---
Failed TheEmployeeAPI.Tests.BasicTests.UpdateEmployee_ReturnsOkResult [1 ms]
Error Message:
System.Net.Http.HttpRequestException : Response status code does not indicate success: 500 (Internal Server Error).
Stack Trace:
at System.Net.Http.HttpResponseMessage.EnsureSuccessStatusCode()
at TheEmployeeAPI.Tests.BasicTests.UpdateEmployee_ReturnsOkResult() in /Users/spencer/Repos/building-apis-with-csharp-and-aspnet-core-code/2-F-adding-validation-to-your-api/TheEmployeeAPI.Tests/UnitTest1.cs:line 58
--- End of stack trace from previous location ---
Failed TheEmployeeAPI.Tests.BasicTests.GetAllEmployees_ReturnsOkResult [< 1 ms]
Error Message:
System.Net.Http.HttpRequestException : Response status code does not indicate success: 500 (Internal Server Error).
Stack Trace:
at System.Net.Http.HttpResponseMessage.EnsureSuccessStatusCode()
at TheEmployeeAPI.Tests.BasicTests.GetAllEmployees_ReturnsOkResult() in /Users/spencer/Repos/building-apis-with-csharp-and-aspnet-core-code/2-F-adding-validation-to-your-api/TheEmployeeAPI.Tests/UnitTest1.cs:line 22
--- End of stack trace from previous location ---
Failed! - Failed: 5, Passed: 0, Skipped: 0, Total: 5, Duration: 8 ms - TheEmployeeAPI.Tests.dll (net8.0)
Okay, so what happened?
In order for our APIs to pick up and use that service, we need to register it with our services first.
builder.Services.AddSingleton<EmployeeRepository>();
But our tests are still not passing fully. It's because we removed the two default employees from our Employee
list. We can fix that by adding it to our test constructor for now:
public BasicTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
var repo = _factory.Services.GetRequiredService<IRepository<Employee>>();
repo.Create(new Employee { FirstName = "John", LastName = "Doe" });
}
Now our tests should be passing.
A couple of notes
- While we're on the right track, our implementation is still far from ideal because it's still using an in-memory list. That said, we've now decoupled our data storage from our data access, which is a good start.
- It's better to not directly use the implementation for our repository and in fact it's very uncommon to do so. Instead, we should use the interface.
builder.Services.AddSingleton<IRepository<Employee>, EmployeeRepository>();
And then update our implementation to match. Note the use of the IRepository<Employee>
abstraction instead of using the "concrete" EmployeeRepository
type directly.
var employeeRoute = app.MapGroup("/employees");
employeeRoute.MapGet(string.Empty, (IRepository<Employee> repository) => {
return Results.Ok(repository.GetAll().Select(employee => new GetEmployeeResponse {
FirstName = employee.FirstName,
LastName = employee.LastName,
Address1 = employee.Address1,
Address2 = employee.Address2,
City = employee.City,
State = employee.State,
ZipCode = employee.ZipCode,
PhoneNumber = employee.PhoneNumber,
Email = employee.Email
}));
});
employeeRoute.MapGet("{id:int}", (int id, IRepository<Employee> repository) => {
var employee = repository.GetById(id);
if (employee == null)
{
return Results.NotFound();
}
return Results.Ok(new GetEmployeeResponse {
FirstName = employee.FirstName,
LastName = employee.LastName,
Address1 = employee.Address1,
Address2 = employee.Address2,
City = employee.City,
State = employee.State,
ZipCode = employee.ZipCode,
PhoneNumber = employee.PhoneNumber,
Email = employee.Email
});
});
employeeRoute.MapPost(string.Empty, (CreateEmployeeRequest employeeRequest, IRepository<Employee> repository) => {
var newEmployee = new Employee {
FirstName = employeeRequest.FirstName,
LastName = employeeRequest.LastName,
SocialSecurityNumber = employeeRequest.SocialSecurityNumber,
Address1 = employeeRequest.Address1,
Address2 = employeeRequest.Address2,
City = employeeRequest.City,
State = employeeRequest.State,
ZipCode = employeeRequest.ZipCode,
PhoneNumber = employeeRequest.PhoneNumber,
Email = employeeRequest.Email
};
repository.Create(newEmployee);
return Results.Created($"/employees/{newEmployee.Id}", employeeRequest);
});
employeeRoute.MapPut("{id}", (UpdateEmployeeRequest employeeRequest, int id, IRepository<Employee> repository) => {
var existingEmployee = repository.GetById(id);
if (existingEmployee == null)
{
return Results.NotFound();
}
existingEmployee.Address1 = employeeRequest.Address1;
existingEmployee.Address2 = employeeRequest.Address2;
existingEmployee.City = employeeRequest.City;
existingEmployee.State = employeeRequest.State;
existingEmployee.ZipCode = employeeRequest.ZipCode;
existingEmployee.PhoneNumber = employeeRequest.PhoneNumber;
existingEmployee.Email = employeeRequest.Email;
repository.Update(existingEmployee);
return Results.Ok(existingEmployee);
});
In the future, this would allow us to model different data sources for different reasons. The main benefit of that being that we could have a separate implementation for an in-memory data source for tests and a different implementation for an honest-to-god database.