I've spoken about the inversion of control pattern before, in essence it is a fundamental design pattern in object oriented languages. It separates the definition of capability from the logic that executes that capability. That may sound a bit strange but what it means is that the class which you interact with defines functions and methods, however the logic which those functions and methods use are defined in a separate class. At run time the class you interact with, either receives or initiates a class which implements the methods and functions needed.
The idea is that you can have multiple implementations for the same methods and then swap out those implementations without modifying your main codebase. There are a number of advantages to writing code this way, one of the primary ones is that this patter facilitates unit testing, however in this post we'll focus on the benefits of having multiple implementations of the same interface.
in the above UML diagram, we define an interface for a repository as well as a person class, we then define two repo classes which would both implement the IRepo interface, finally the Program class would either receive a concrete implementation of the IRepo class or it would receive instructions on which concrete implementation to use. (I've minimised the number of arrows to try and keep the diagram somewhat readable)
Let's try and implement the above in our Minimal API, start by creating the following folder structure with classes and interfaces
It's common to have a separate folder for all interfaces, personally I prefer to have my interfaces closer to their concrete classes, to be honest it really doesn't matter how you do it, what matters is that you are consistent, personally this is the way I like to set up my projects, but there's nothing wrong with doing it differently.
So let's start with the IPerson interface
namespace pav.mapi.example.models
{
public interface IPerson
{
string Id { get; }
string FirstName { get; set; }
string LastName { get; set; }
DateOnly? BirthDate { get; set; }
}
}
as you can see from the namespace, I don't actually separate it from the Person class, the folder is just there as an easy way to group our model interfaces. Next let's implement our Person class
using pav.mapi.example.models;
namespace pav.mapi.example.models
{
public class Person : IPerson
{
public string Id { get; set; } = "";
public string FirstName { get; set; } = "";
public string LastName { get; set; } = "";
public DateOnly? BirthDate { get; set; } = null;
public Person(){}
public Person(string FirstName, string LastName, DateOnly BirthDate) : this()
{
this.Id = Guid.NewGuid().ToString();
this.FirstName = FirstName;
this.LastName = LastName;
this.BirthDate = BirthDate;
}
public int getAge()
{
var today = DateOnly.FromDateTime(DateTime.Now);
var age = today.Year - this.BirthDate.Value.Year;
return this.BirthDate > today.AddYears(-age) ? --age : age;
}
public string getFullName()
{
return $"{this.FirstName} {this.LastName}";
}
}
}
An important thing to call out is, that this is a contrived example, setting the ID property with both a public setter as well as a public getter is not ideal, however I want to focus on the Inversion of control pattern and not get bogged down in the ideal ways of defining properties; I may tackle this in a future post.
Now on to defining our repo interface, simply define two functions, one to get people and one to get a specific person.
using pav.mapi.example.models;
namespace pav.mapi.example.repos
{
public interface IRepo
{
public Task<IPerson> GetPersonAsync(string id);
public Task<IPerson[]> GetPeopleAsync();
}
}
with that done, let's implement our Local repo, it's going to be very simple we are going to use use our path from our local appsettings file to load a JSON file full of people.
using System.Text.Json;
using pav.mapi.example.models;
namespace pav.mapi.example.repos
{
public class LocalRepo : IRepo
{
private string _filePath;
public LocalRepo(string FilePath)
{
this._filePath = FilePath;
}
public async Task<IPerson[]> GetPeopleAsync()
{
var opt = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
try
{
using (FileStream stream = File.OpenRead(this._filePath))
{
var ppl = await JsonSerializer.DeserializeAsync<Person[]>(stream, opt);
if (ppl != null)
return ppl;
throw new Exception();
}
}
catch (Exception)
{
throw;
}
}
public async Task<IPerson> GetPersonAsync(string id)
{
var people = await this.GetPeopleAsync();
var person = people?.First(p => p.Id == id);
if (person != null)
return person;
throw new KeyNotFoundException($"no person with id {id} exitsts");
}
}
}
Next let's implement our DockerRepo
using pav.mapi.example.models;
namespace pav.mapi.example.repos
{
public class DockerRepo : IRepo
{
public Task<IPerson[]> GetPeopleAsync()
{
throw new NotImplementedException();
}
public Task<IPerson> GetPersonAsync(string id)
{
throw new NotImplementedException();
}
}
}
For now we'll just leave it unimplemented, later on when we set up a docker container, we'll implement our functions.
Finally let's specify which implementation to use based on our loaded profile in our Main
using pav.mapi.example.models;
using pav.mapi.example.repos;
namespace pav.mapi.example
{
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
var flatFileLocation = builder.Configuration.GetValue<string>("flatFileLocation");
if (String.IsNullOrEmpty(flatFileLocation))
throw new Exception("Flat file location not specified");
if(builder.Environment.EnvironmentName != "Production")
switch (builder.Environment.EnvironmentName)
{
case "Local":
builder.Services.AddScoped<IRepo, LocalRepo>(x => new LocalRepo(flatFileLocation));
goto default;
case "Development":
builder.Services.AddScoped<IRepo, DockerRepo>(x => new DockerRepo());
goto default;
default:
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
break;
}
var app = builder.Build();
switch (app.Environment.EnvironmentName)
{
case "Local":
case "Development":
app.UseSwagger();
app.UseSwaggerUI();
break;
}
app.MapGet("/v1/dataSource", () => flatFileLocation);
app.MapGet("/v1/people", async (IRepo repo) => await GetPeopleAsync(repo));
app.MapGet("/v1/person/{personId}", async (IRepo repo, string personId) => await GetPersonAsync(repo, personId));
app.Run();
}
public static async Task<IPerson[]> GetPeopleAsync(IRepo repo)
{
return await repo.GetPeopleAsync();
}
public static async Task<IPerson> GetPersonAsync(IRepo repo, string personId)
{
var people = await GetPeopleAsync(repo);
return people.First(p => p.Id == personId);
}
}
}
Before we test our code, we first need to setup a flat file somewhere on our hard drive,
[
{
"id": "1",
"firstName": "Robert",
"lastName": "Smith",
"birthDate": "1984-01-26"
},
{
"id": "2",
"firstName": "Eric",
"lastName": "Johnson",
"birthDate": "1988-08-28"
}
]
I created the above file in the directory "/volumes/dev/people.json", it's important to know the path because in the next step we are going to add it to our appsettings.Local.json file
{
"flatFileLocation": "/Volumes/dev/people.json"
}
With all that complete let's go into our main terminal and run our api with our local profile
dotnet watch run -lp local --trust
in our Swagger we should see three get endpoints we can call
Each one of these should work
/v1/dataSource should return where our flatfile is located on our local computer
/Volumes/dev/people.json
/v1/people should return the contents of our people.json file
[
{
"id": "1",
"firstName": "Robert",
"lastName": "Smith",
"birthDate": "1984-01-26"
},
{
"id": "2",
"firstName": "Eric",
"lastName": "Johnson",
"birthDate": "1988-08-28"
}
]
/v1/person/{personId} should return the specified person.
{
"id": "2",
"firstName": "Eric",
"lastName": "Johnson",
"birthDate": "1988-08-28"
}
Next if we stop our api (cntrl+c) and restart it with the following command
dotnet watch run -lp http --trust
we again run our application however this time with the development profile, if we navigate to our swagger and call our functions the only one which will work is
/v1/dataSource which should return the placeholder we specified in our appsettings.Development.json file.
Dev
the other two endpoints should both fail, since we never implemented them