Refactoring to request classes and adding a PUT request

Let's do some refactoring and add PUT requests to our API. The goal here is to walk through various challenges and decision points you'll encounter when building APIs, especially as they get bigger and more complex.

First, let's expand our Employee class a bit to include some additional properties, like their address and some contact information:

public class Employee 
{
    public int Id { get; set; }
    public required string FirstName { get; set; }
    public required string LastName { get; set; }
    public required string SocialSecurityNumber { get; set; }

    public string? Address1 { get; set; }
    public string? Address2 { get; set; }
    public string? City { get; set; }
    public string? State { get; set; }
    public string? ZipCode { get; set; }
    public string? PhoneNumber { get; set; }
    public string? Email { get; set; }
}

Pretending for a second that we're building an API for a real system - you get handed down the following requirements:

  1. Employee first name and last name are always required.
  2. We need to be able to update their first name, last name, address, phone number, and email at any time.

Let's start with PUT requests

PUT requests are typically used to update an existing resource. We can add a handler for this quite easily:

employeeRoute.MapPut("{id}", (Employee employee, int id) => {
    var existingEmployee = employees.SingleOrDefault(e => e.Id == id);
    if (existingEmployee == null)
    {
        return Results.NotFound();
    }

    existingEmployee.FirstName = employee.FirstName;
    existingEmployee.LastName = employee.LastName;
    existingEmployee.Address1 = employee.Address1;
    existingEmployee.Address2 = employee.Address2;
    existingEmployee.City = employee.City;
    existingEmployee.State = employee.State;
    existingEmployee.ZipCode = employee.ZipCode;
    existingEmployee.PhoneNumber = employee.PhoneNumber;
    existingEmployee.Email = employee.Email;

    return Results.Ok(existingEmployee);
});

There are some things we can do to reduce the amount of boilerplate for setting properties - AutoMapper comes to mind - but we'll skip that for now and just set them explicitly for simplicity.

Now we can write a test for this endpoint:

[Fact]
public async Task UpdateEmployee_ReturnsOkResult()
{
    var client = _factory.CreateClient();
    var response = await client.PutAsJsonAsync("/employees/1", new Employee { FirstName = "John", LastName = "Doe" });

    response.EnsureSuccessStatusCode();
}

And run the tests again. Yay, everything is working!

There are additional things we should do like check to make sure our properties were actually updated, but we'll worry about that later.

The New Requirements are handed down

Boss calls and says that we have a change request - someone was attempting to mess with the system and so now we must go through a separate workflow if they want to change their first, last name, and social security number. They also want to hide the social security number from the GET requests and want to require getting that information to go thru a separate workflow as well. (We'll worry about everything but the last part.)

Our GET endpoints and PUT endpoint is officially obsolete now, so let's remove the parts that allow our users to change their first and last name:

employeeRoute.MapPut(string.Empty, (Employee employee) => {
    var existingEmployee = employees.SingleOrDefault(e => e.Id == employee.Id);
    if (existingEmployee == null)
    {
        return Results.NotFound();
    }

    //existingEmployee.FirstName = employee.FirstName;
    //existingEmployee.LastName = employee.LastName;
    existingEmployee.Address1 = employee.Address1;
    existingEmployee.Address2 = employee.Address2;
    existingEmployee.City = employee.City;
    existingEmployee.State = employee.State;
    existingEmployee.ZipCode = employee.ZipCode;
    existingEmployee.PhoneNumber = employee.PhoneNumber;
    existingEmployee.Email = employee.Email;

    return Results.Ok(existingEmployee);
});

And we can remove SSN from our GET requests:

employeeRoute.MapGet(string.Empty, () => {
    foreach (var employee in employees)
    {
        employee.SocialSecurityNumber = null; // We don't want to return the SSN
    }
    return employees;
});

employeeRoute.MapGet("{id:int}", (int id) => {
    var employee = employees.SingleOrDefault(e => e.Id == id);
    if (employee == null)
    {
        return Results.NotFound();
    }
    employee.SocialSecurityNumber = null; // We don't want to return the SSN
    return Results.Ok(employee);
});

Yay, job done... right? Well, not quite. We've now got a mismatch between our storage and our request objects.

This might not seem like a big deal, but in reality, it becomes a bigger problem as your system grows in complexity:

  1. The real world isn't made up of dumb GET/PUT/POST/DELETE requests that just update single objects and move on. The real world has more complex workflows than that, and you will quickly find that trying to model that complexity using entity objects is completely impractical.
  2. You will work in a system where the modeling between API calls and databases simply doesn't work. You need something that allows the two things to live separately in order to maintain separation of concerns (e.g. a concept that a thing has a singular purpose).

🌶️🌶️🌶️ In the kitchen, we may not like unitaskers (see: Alton Brown), but in programming, we LOVE them. Things that serve a singular purpose are easier to understand, test, and maintain.

So, to that end, let's add an Employee folder and populate it with some new classes that fit into our requirements:

public class CreateEmployeeRequest
{
    public required string FirstName { get; set; }
    public required string LastName { get; set; }
    public string? SocialSecurityNumber { get; set; }

    public string? Address1 { get; set; }
    public string? Address2 { get; set; }
    public string? City { get; set; }
    public string? State { get; set; }
    public string? ZipCode { get; set; }
    public string? PhoneNumber { get; set; }
    public string? Email { get; set; }
}

public class GetEmployeeResponse
{
    public required string FirstName { get; set; }
    public required string LastName { get; set; }

    public string? Address1 { get; set; }
    public string? Address2 { get; set; }
    public string? City { get; set; }
    public string? State { get; set; }
    public string? ZipCode { get; set; }
    public string? PhoneNumber { get; set; }
    public string? Email { get; set; }
}

public class UpdateEmployeeRequest
{
    public string? Address1 { get; set; }
    public string? Address2 { get; set; }
    public string? City { get; set; }
    public string? State { get; set; }
    public string? ZipCode { get; set; }
    public string? PhoneNumber { get; set; }
    public string? Email { get; set; }
}

Now we have separate classes for our requests/responses:

var employeeRoute = app.MapGroup("/employees");

employeeRoute.MapGet(string.Empty, () => {
    return Results.Ok(employees.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) => {
    var employee = employees.SingleOrDefault(e => e.Id == 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 employee) => {
    var newEmployee = new Employee {
        Id = employees.Max(e => e.Id) + 1,
        FirstName = employee.FirstName,
        LastName = employee.LastName,
        SocialSecurityNumber = employee.SocialSecurityNumber,
        Address1 = employee.Address1,
        Address2 = employee.Address2,
        City = employee.City,
        State = employee.State,
        ZipCode = employee.ZipCode,
        PhoneNumber = employee.PhoneNumber,
        Email = employee.Email
    };
    employees.Add(newEmployee);
    return Results.Created($"/employees/{newEmployee.Id}", employee);
});

employeeRoute.MapPut("{id}", (UpdateEmployeeRequest employee, int id) => {
    var existingEmployee = employees.SingleOrDefault(e => e.Id == id);
    if (existingEmployee == null)
    {
        return Results.NotFound();
    }

    existingEmployee.Address1 = employee.Address1;
    existingEmployee.Address2 = employee.Address2;
    existingEmployee.City = employee.City;
    existingEmployee.State =    employee.State;
    existingEmployee.ZipCode = employee.ZipCode;
    existingEmployee.PhoneNumber = employee.PhoneNumber;
    existingEmployee.Email = employee.Email;

    return Results.Ok(existingEmployee);
});

We've also rerun our tests and they're still working, which means we have confidence that our refactor worked fine:

❯ dotnet test
  Determining projects to restore...
  All projects are up-to-date for restore.
  TheEmployeeAPI -> /Users/spencer/Repos/building-apis-with-csharp-and-aspnet-core-code/2-E-refactoring-to-repo-pattern/TheEmployeeAPI/bin/Debug/net8.0/TheEmployeeAPI.dll
  TheEmployeeAPI.Tests -> /Users/spencer/Repos/building-apis-with-csharp-and-aspnet-core-code/2-E-refactoring-to-repo-pattern/TheEmployeeAPI.Tests/bin/Debug/net8.0/TheEmployeeAPI.Tests.dll
Test run for /Users/spencer/Repos/building-apis-with-csharp-and-aspnet-core-code/2-E-refactoring-to-repo-pattern/TheEmployeeAPI.Tests/bin/Debug/net8.0/TheEmployeeAPI.Tests.dll (.NETCoreApp,Version=v8.0)
VSTest version 17.11.0 (arm64)

Starting test execution, please wait...
A total of 1 test files matched the specified pattern.

Passed!  - Failed:     0, Passed:     5, Skipped:     0, Total:     5, Duration: 15 ms - TheEmployeeAPI.Tests.dll (net8.0)