5 Setting up a project and database with Entity Framework Core

This chapter covers

  • Refactoring a legacy codebase to be clean and secure
  • Using Entity Framework Core to query a database
  • Implementing the repository/service pattern
  • Creating a new .NET 5 solution and project using the command line

The time has finally come. You are probably eager to fix some of the issues we saw in chapters 3 and 4, and now we’ll get to do that. First things first, let’s come up with a game plan on how to tackle this refactoring. We already know a couple of things that we need to do differently:

  • In chapter 3 we were told to use .NET 5 instead of the .NET Framework for the new version of the Flying Dutchman Airlines service.

  • We need to rewrite the endpoints to be clean code (in particular, adhering to the DRY principle).

  • We need to fix the security vulnerability—a hardcoded connection string.

  • The object names do not match the database column names. We should fix that to ensure a perfect isomorphic relationship between the codebase and the database.

  • We need to adhere to the OpenAPI file discussed in chapter 3 and shown in appendix D.

Although not necessarily part of the requirements, we would like to include some additional deliverables to improve the quality of the job, thus ensuring a job well done:

  • We want to use test-driven development to write unit tests that back the codebase.

  • We want to use Entity Framework Core to revamp the database layer by reverse-engineering the deployed database.

  • We want to autogenerate an updated OpenAPI file on the launch of the service to compare against the provided OpenAPI from FlyTomorrow.

Of course, we will do much more than just these improvements, but it is good to have some general broad strokes in mind. We are also in a very interesting position: we are stuck somewhere in the middle of having to keep the old codebase alive and working and greenfield development.

DEFINITION Greenfield development means that we are working on a project that isn’t held back by any previous design decisions or old code. In practice, this usually means a brand-new project.

We have set requirements and an old codebase that we need to mimic (where appropriate and possible), but we also start with an empty project. In the real world, you will often encounter this scenario. You no doubt have had the experience of trying to create a new version of an existing product—a “next-gen” version, if you will. Figure 5.1 shows where we are in the scheme of the book.

Figure 5.1 In this chapter, we’ll start the process of reimplementing the Flying Dutchman Airlines codebase. We’ll start with the database access layer. In the following chapters, we’ll look at the repository layer, service layer, and controller layer.

Our first order of business is to create a new .NET 5 solution.

5.1 Creating a .NET 5 solution and project

In this section, we’ll create a new .NET 5 solution and project. We’ll also look at what predefined solution and project templates exist for .NET 5. You have the following two ways to create a new .NET 5 solution:

  • You can use a command line, be it the Windows command line or a macOS/ Linux terminal.

  • You can use an IDE like Visual Studio. Using Visual Studio automates the process somewhat. Most things you can do in a command line or terminal with C# you can also do in Visual Studio with a couple of clicks.1

The outcome of using either route is the same: you end up with a new .NET 5 solution. We’ll be using the command line. Creating a new, empty .NET 5 solution or project is very simple, as shown here:

> dotnet new [TEMPLATE] [FLAGS]

NOTE Before you attempt to create a .NET 5 project, please make sure you have installed the latest .NET 5 SDK and runtime. Installation instructions are in appendix C.

We can use a variety of templates. Some of the more common ones are web, webapp, mvc, and webapi. For our purposes, we use perhaps two of the most popular of all: sln and console. The dotnet new sln command creates a new solution, whereas dotnet new console creates a new project and a “hello, world” source file. As discussed in section 3.3.2, C# uses solutions and projects to organize its codebases. A solution is the top-level entity and contains multiple projects. We write our logic within the projects. Projects can be thought of as different modules, packages, or libraries, depending on our language of preference.

We also pass the -n flag along with the creation command. This allows us to specify a name for our solution and project. If we do not explicitly specify a name for our solution, the name of our project or solution defaults to the name of the folder in which we create the files.

To create our starting point, run the following command. Note that the command-line tool does not let you create a new solution folder when creating a new solution. If you want to do this, you can either use Visual Studio (which does allow for it) or create the folder first and then run the following command in the solution folder.

> dotnet new sln -n "FlyingDutchmanAirlinesNextGen"

The command creates only one thing: a solution file called FlyingDutchmanAirlinesNextGen.sln, shown in figure 5.2. We could open this solution file in Visual Studio, but we cannot do much without a project.

Figure 5.2 After running the command to create a new .NET solution, the command line lets us know that the operation was successful.

Now that we have a solution file, we should create a project called FlyingDutchmanAirlines. To create a new project, we use the console template, as shown next. This creates a .NET 5 console application, which we’ll then change to be a web service.

> dotnet new console -n "FlyingDutchmanAirlines"

After running the command, we are greeted by a message saying that “Restore succeeded.” A restore is a process that the .NET CLI performs before the creation of a new project and before compiling after a “clean” operation (“clean” deletes all local executable files, including dependencies) or first compilation, to gather required dependencies. We can also run this command on its own by saying

> dotnet restore

A restore can come in handy when dealing with dependency troubles. The restore command also creates a new folder next to our solution file called FlyingDutchmanAirlines (the same as the project name we passed in), as shown in figure 5.3. When we enter the folder, we see another folder called obj. The obj folder contains configuration files for NuGet and its packages. Back in the root folder for the project, we have a project file and a C# source file.

Figure 5.3 The folder structure after running the command-line commands to create a solution and project. The FlyingDutchmanAirlines folder was created using the command to create a project, whereas the FlyingDutchmanAirlinesNextGen.sln file was created using the command to create a new solution.

Our project is created, but we still need to add it to the solution. When you create a project, dotnet does not scan any subdirectories looking for a contained solution. To add a project to a solution, use the “solution add” command as follows:

> dotnet sln [SOLUTION PATH] add [PROJECT PATH]

The [SOLUTION PATH] points to the path of the solution file to which you want to add a project. The [PROJECT PATH], similarly, points to a csproj file to be added to the solution. You can add multiple projects at the same time by adding multiple [PROJECT PATH] arguments to the command.

In our situation, running from the root FlyingDutchmanAirlinesNextGen folder, the command takes just the one csproj into account, as shown here:

> dotnet sln FlyingDutchmanAirlinesNextGen.sln add
 .FlyingDutchmanAirlinesFlyingDutchmanAirlines.csproj

The terminal lets us know with a message—Project `FlyingDutchmanAirlines FlyingDutchmanAirlines.csproj` added to the solution.—that we were successful in our effort. If we open up the FlyingDutchmanAirlinesNextGen.sln file in a text editor, we see a reference to the FlyingDutchmanAirlines.csproj file as follows:

Project("{...}") = 
 "FlyingDutchmanAirlines",
 "FlyingDutchmanAirlinesFlyingDutchmanAirlines.csproj", "{...}"
EndProject

This is the reference added by the solution add command. The reference tells an IDE and the compiler that there is a project with the name FlyingDutchmanAirlines as part of this solution.

5.2 Setting up and configuring a web service

In section 5.1 we created a new solution and project to use for the next-gen version of the Flying Dutchman Airlines service. In this section, we’ll look at the source code generated as a result of the actions we took in section 5.1 and configure the console application to function as a web service.

The only source file in the solution (and project) at this point is Program.cs, shown in the next listing. This file is automatically generated through the console template we used in section 5.1 to create a new project. It contains the entry point for the program—a static method called Main—which returns nothing. Here, it also accepts a string array called args. This array contains any command-line arguments passed in on launch.

Listing 5.1 Program.cs with the Main method

using System;
 
namespace FlyingDutchmanAirlines {
  class Program {
    static void Main(string[] args) {        
      Console.WriteLine("Hello World!");     
    }                                        
  }
}

A static void Main is the default entry point for a C# console application.

Using the command line to run the FlyingDutchmanAirlinesNextGen project, it outputs “Hello World!” to the console. Let’s remove the "Hello World!" string from the code. This puts us in a good spot to change the console application to something more functional: a web service.

5.2.1 Configuring a .NET 5 web service

We need to configure our brand-new .NET 5 app to accept HTTP requests and route them to the endpoints we’ll implement. To do this, we also need to set up Host, which is the underlying process that runs the web service and interacts with the CLR. Our application lives inside the Host, which in turn lives inside the CLR.

NOTE We can draw similarities between web containers (such as IIS) and Tomcat. To put it in Java terms, .NET 5 is your JVM and Spring, whereas Host is your Tomcat.

We configure Host to launch a “host process” that is responsible for app startup and lifetime management. We also tell Host that we want to use WebHostDefaults. This allows us to use Host for a web service, as shown in figure 5.4. At a minimum, the host configures the server and request-processing pipeline.

Figure 5.4 A web service runs inside the Host, which runs inside the CLR. This model allows the CLR to spin up a Host that can execute our web service.

My preferred way of configuring the Host in .NET 5. is to follow these three steps:

  1. Use the CreateDefaultBuilder method on the static Host class (part of the Microsoft.Extensions.Hosting namespace) to create a builder.

  2. Configure the Host builder by telling it we want to use WebHostDefaults and set a startup class and a startup URL with a port specified.

  3. Build and run the built Host instance.

When we try to configure a startup class for our builder’s returned Host instance, we have to use the UseStartup class. This comes as part of ASP.NET, which is not installed through .NET 5 by default. To access this functionality (and anything in ASP.NET), we need to add the ASP.NET package to the FlyingDutchmanAirlines project. We can do this through the NuGet package manager in Visual Studio or through our trusty command line when we are inside the project folder, as follows:

> dotnet add package Microsoft.AspNetCore.App

After executing the command, the command line lets you know that the package was successfully added to the project.

NOTE The command also executes a restore action. For more details on restore, see section 5.1.

If we try to build the project now, we get a warning saying that we should be using a framework reference instead of a package reference. This is due to some shuffling that went on with .NET namespaces in the last couple of years. This warning doesn’t prohibit us from using the code as it is now, but we can get rid of it pretty easily. In a text editor such as Notepad++ or (for the brave) Vim, open the FlyingDutchmanAirlines.csproj file. In that file, add the boldface code and remove the package reference to ASP.NET:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net5.0</TargetFramework>
  </PropertyGroup>
 
 <ItemGroup>
      <FrameworkReference Include="Microsoft.AspNetCore.App" />
 </ItemGroup>
 
 <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.App" Version="2.2.8" /> 
    ...
  </ItemGroup>
</Project>

Now that the Microsoft.AspNetCore package is installed (as a framework reference), and we got rid of the compiler warning, we can use ASP.NET functionality. The first thing we want to do is tell the compiler we want to use the AspNetCore.Hosting namespace, as shown in the next listing. In this book, the namespace imports are often omitted from code listings. This is done because they take up precious space and can be autopopulated in most IDEs.

Listing 5.2 Program.cs with no “Hello, World!” output

using System;
using Microsoft.AspNetCore.Hosting;    
 
namespace FlyingDutchmanAirlines {
  class Program {
    static void Main(string[] args) {
                                       
    }
  }
}

We use the Microsoft.AspNetCore.Hosting namespace.

We no longer output “Hello, World!” to the console.

5.2.2 Creating and using HostBuilder

In this section, we’ll

  1. Create an instance of HostBuilder.

  2. Say we want to use the Host as a web service.

  3. Set the startup URL to be http://0.0.0.0:8080.

  4. Build an instance of Host using HostBuilder.

  5. Run the Host.

In the program’s Main method, we add a call to Host.CreateDefaultBuilder. This call returns a HostBuilder, with some defaults already. We then tell the resulting builder we want to use a specific URL and port by calling UseUrls. Then we call Build to build and return the actual Host. We assign the output to a variable of type IHost. We assign our new Host to an explicitly typed variable of type IHost. Finally, the code starts the Host by calling host.Run(), as shown next:

using System;
using Microsoft.AspNetCore; 
using Microsoft.AspNetCore.Hosting;
 
namespace FlyingDutchmanAirlines {
  class Program {
    static void Main(string[] args) {
      IHost host = 
 Host.CreateDefaultBuilder().ConfigureWebHostDefaults(builder => {
        builder.UseUrls("http:/ /0.0.0.0:8080");
      }).Build();
 
      host.Run();    
    }
  }
}

If you try to compile and run the service in this state, the service launches but then terminates with an InvalidOperationException. This exception tells us we do not have a Startup class configured and tied to the Host. But before we create this Startup class, let’s leave the Program class in the best shape possible. We have our Host creation and call to Run in the Main method, but should it really be in there?

In section 1.4, we discussed the importance of writing methods that read like a narrative. If I am a new developer, looking at a public method (in this case Main), I probably do not care about implementation details. Instead, I want to get an understanding of the broad strokes of what the method does. To that end, we can extract the initialization and assignment of host and the call to host.Run into a separate, private method as follows:

private static void InitalizeHost() {
  IHost host = Host.CreateDefaultBuilder()
      .ConfigureWebHostDefaults(builder =>
    {
      builder.UseUrls("http:/ /0.0.0.0:8080");
    }).Build();
 
  host.Run();
}

Having extracted the Host creation logic into a separate method is a good step, but we can do just a bit more. We should consider two other things. First, we don’t need to store the result of the HostBuilder in a variable, because we use it only to call Run. Why don’t we just call Run directly after Build and avoid the unnecessary memory assignment, as shown next:

IHost host = Host.CreateDefaultBuilder()
      .ConfigureWebHostDefaults(builder =>
    {
      builder.UseUrls("http:/ /0.0.0.0:8080");
    }).Build().Run();

  

The second thing we should consider is changing the method to an “expression” method, as shown next. Similar to a lambda expression, an expression method uses => notation to indicate that the method will evaluate the expression to the right of the => and return its result. You can think of the => operator as a combination of assignment and evaluation algebraically (=) and a return statement (>). Lambda expressions may look a bit funny at first, but the more you see them, the more you want to use them.

private static void InitalizeHost() => 
  Host.CreateDefaultBuilder()
    .ConfigureWebHostDefaults(builder =>
  {
    builder.UseUrls("http:/ /0.0.0.0:8080");
  }).Build().Run();

How does this impact our Main method? Not much. All we have to do is call the InitializeHost method as follows:

namespace FlyingDutchmanAirlines {
  class Program {
    static void Main(string[] args) {
      InitializeHost();
    }
    
    private static void InitalizeHost() => 
      Host.CreateDefaultBuilder()
        .ConfigureWebHostDefaults(builder =>
      {
        builder.UseUrls("http:/ /0.0.0.0:8080");
      }).Build().Run();  
  }
}

Our code is clean and readable, but we still have that runtime exception to deal with. Clean code is nice, but if it doesn’t have the required functionality, it isn’t good enough. The exception said that we need to register a Startup class with the HostBuilder before we build and run the resulting IHost. I guess we have no choice but to make that our next item of work.

5.2.3 Implementing the Startup class

We do not have a Startup class yet, but we can remedy that by creating a file called Startup.cs (in the project’s root folder is fine for this purpose) as follows:

namespace FlyingDutchmanAirlines {
  class Startup { }
}

To configure our Startup class, create a Configure method in the Startup class. This method is called by the HostBuilder and contains a crucial configuration option, shown in the next listing, which allows us to use controllers and endpoints.

Listing 5.3 Startup.cs Configure method

public void Configure(IApplicationBuilder app) {
  app.UseRouting();                                            
  app.UseEndpoints(endpoints => endpoints.MapControllers());   
}

Uses routing and makes routing decisions for the service in this class

Uses an endpoint pattern for routing web requests. MapControllers scans and maps all the controllers in our service.

The small method in listing 5.3 is the core of our configuration code. When UseRouting is called, we tell the runtime that certain routing decisions for the service are made in this class. If we did not have the call to UseRouting, we would not be able to hit any endpoint. UseEndpoints does what it says it does: it allows us to use and specify endpoints. It takes in an argument of a type we have not encountered before: Action. This is an instance of a delegate.

Delegates and anonymous methods

A delegate provides a way to reference a method. It is also type-safe, so it can point only to a method with a given signature. The delegate can be passed around to other methods and classes and then invoked when wanted. They are often used as callbacks.

You can create delegates in one of the following three ways:

  • Using the delegate keyword

  • Using an anonymous method

  • Using a lambda expression

The oldest way of creating them is by explicitly declaring a type of delegate and creating a new instance of that delegate by assigning a method to the delegate as follows:

delegate int MyDelegate(int x);
public int BeanCounter(int beans) => beans++;
 
public void AnyMethod(){
  MyDelegate del = BeanCounter;
}

This code is readable but a bit clumsy. As C# matured, new ways were introduced to work with delegates.

The second option is to use an anonymous method. To create a delegate with an anonymous method, we specify the method return type and body inside a new delegate instantiation, as shown here:

delegate int MyDelegate(int x);
public void AnyMethod() {
  MyDelegate del = delegate(int beans) { 
    return beans++;
  };
}

Notice the difference between the original and anonymous ways of creating a delegate. An anonymous method can clean up your code tremendously but comes with a big warning: you should use an anonymous method only if you are required to do so or if you are confident that you can adhere to the DRY principle. If you need to execute the same logic somewhere else in your codebase and you are not passing in the delegate to that location, use a normal method instead and call it from both places.

The third, and current, evolution of this process is a fairly easy step to reach from the anonymous method: lambda expressions, shown next:

delegate int MyDelegate(int x);
public void AnyMethod() {
  MyDelegate del = beans => beans++;
}

We simply determine what we want the input to be in our anonymous method (beans) and what logic we want to perform and return (beans++). Additionally, you can add and subtract methods from a delegate by using the addition (+) and subtraction (-) operators. If you have multiple methods tied to the same delegate, the delegate becomes a multicast delegate.

Finally, to use a delegate, call the Invoke method, shown next. This invokes the underlying Action, executing whatever code you have attached to it.

del.Invoke();

We pass in a lambda expression, which when executed will configure the app’s endpoints by calling MapControllers. A handy method, MapControllers scans our codebase for any controllers and generates the appropriate routes to the endpoints in our controllers.

The only thing remaining to do before registering our Startup class with the Host is to create a ConfigureServices method and call AddControllers on the passed-in IServiceCollection, as shown in the next code sample. The IServiceCollection interface allows us to add functionalities to the service, such as support for controllers or dependency-injected types. These functionalities get added to an internal service collection.

public void ConfigureServices(IServiceCollection services) {
  services.AddControllers();
}

Why do we need to add controller support to the service collection? Didn’t we just scan for the controllers and add routes to the RouteTable? At runtime, Host first calls ConfigureServices, giving us a chance to register any services we want to use in our app (in this case, our controllers). If we skipped this step, MapControllers would not find any controllers.

To use IServiceCollection, we need to use the Microsoft.Extensions .DependencyInjection namespace, shown in the next code snippet. Dependency injection is used by the runtime to provide the current, up-to-date ServiceCollection. You can find more information about dependency injection in section 6.2.9.

namespace FlyingDutchmanAirlines {
  class Startup {
    public void Configure(IApplicationBuilder app){
      app.UseRouting();
      app.UseEndpoints(endpoints => endpoints.MapControllers(); }); 
    }    
 
    public void ConfigureServices (IServiceCollection services) {
      services.AddControllers();
    }
  }
}

We are done with the Startup class. Now, let’s configure it to be used by the HostBuilder. We do this by going back to Program.cs and adding a call to UseStartup <Startup>() to the HostBuilder:

namespace FlyingDutchmanAirlines {
  class Program {
    static void Main(string[] args) {
      InitializeHost();
    }
    
    private static void InitalizeHost() => 
      Host.CreateDefaultBuilder()
        .ConfigureWebHostDefaults(builder =>
      {
        builder.UseStartup<Startup>();
        builder.UseUrls("http:/ /0.0.0.0:8080");
      }).Build().Run();  
  }
}

Now when we launch the application, we get a console window telling us that the service is running and listening on http://0.0.0.0:8080. This code looks slightly different from what the autogenerated template would give us. The functionality remains the same, and both are good jumping-off points.

Now that we have the prerequisites out of the way, we can start adding some logic to our service.

5.2.4 Using the repository/service pattern for our web service architecture

The architectural paradigm we plan to use for the Flying Dutchman Airlines next-gen service is the repository/service pattern. With this pattern, we use an upside-down development strategy, where we work from the bottom up: first implement the low-level database calls, then work our way up to creating the endpoints.

Our service architecture comprises the following four layers:

  1. The database access layer

  2. The repository layer

  3. The service layer

  4. The controller layer

Figure 5.5 The repository pattern used in FlyingDutchmanAirlinesNextGen.sln. Data and user queries flow from the controller to the service to the repository to the database. This pattern allows us to easily separate concerns between layers and do incremental development.

The benefit we get by working from the bottom up is that the code complexity grows organically. Typically, that would be a very bad thing. But in this case, we have the tools to control this growth and keep it in check.

We can examine the data flow of our architecture (figure 5.5) by taking any endpoint and walking through the required steps to satisfy the requirements. For example, let’s take POST /Booking/{flightNumber}. First, an HTTP request enters the Booking controller. That would have an instance of a BookingService (every entity will have its own service and repository), which would call the BookingRepository and any other services it needs for any entity it may need to interact with. Then the BookingRepository calls any appropriate database methods. At that point, the flow is reversed, and we go back up the chain to return the result value to the user.

As mentioned before and shown in figure 5.6, all entities have their own set of service and repository classes. If there is a need for an operation on another entity, the initial service makes the call to that entity’s service to request the operation to be performed.

Figure 5.6 The repository pattern applied to the database entities. The FlightController holds instances of a service for every entity it needs to operate on. An entity’s service holds (at least) an instance of the respective entities’ repositories. Services can call other repositories, if necessary. This graphic traces the dependencies flow for Airport (the colored boxes).

5.3 Implementing the database access layer

If we look back at chapter 4, we are reminded of the curious way that database access was handled in the previous version of the application. The connection string was hardcoded into the class itself and no ORM was used. To refresh our minds: an object-relational mapping tool is used to map code against a database, ensuring a good match (or isomorphic relationship). Our two major goals in this section are to

  1. Set up Entity Framework Core and “reverse-engineer” the deployed database.

  2. Store the connection string securely through the use of an environment variable.

One of the most powerful features of Entity Framework Core is the ability to “reverse-engineer” a deployed database. Reverse-engineering means that Entity Framework Core autogenerates all the models in your codebase from a deployed database, saving you a lot of time. Reverse-engineering also guarantees that your models work with the database and are mapped correctly to the schema. In chapter 3, we discussed the need for a correct isomorphic relationship between model and schema, and using an ORM tool to reverse-engineer models is a way to achieve that.

5.3.1 Entity Framework Core and reverse-engineering

In this section, we’ll learn how to use Entity Framework Core to reverse-engineer the deployed database and automatically create models to match the database’s tables. Because we reverse-engineer the database, we can be assured that we are working with compatible code to query the database.

To reverse-engineer our database, we first need to install Entity Framework Core by running the dotnet install command, as shown next. Entity Framework Core (EF Core) does not come automatically installed with .NET 5 as it is a separate project.

> dotnet tool install -–global dotnet-ef

On success, the command line lets you know that you can invoke the tool by using the dotnet-ef command and which version you just installed. Entity Framework Core can connect to many different types of databases. Most databases (SQL, NoSQL, Redis) have packages (also called database drivers) that allow Entity Framework Core to connect to them. Because our database is a SQL Server, we install the respective driver. We also need to add the Entity Framework Core Design package. These packages contain the functionality we need to connect to a SQL Server database (the SqlServer namespace) and reverse-engineer the models (the Design namespace).

Make sure you run the following commands from your project’s root folder (FlyingDutchmanAirlines, not the solution’s root folder, FlyingDutchmanAirlinesNextGen):

> dotnet add package Microsoft.EntityFrameworkCore.SqlServer
> dotnet add package Microsoft.EntityFrameworkCore.Design

The commands install all required packages and dependencies for connecting to a SQL Server with the help of Entity Framework Core.

We can now reverse-engineer the database by using the next command:

> dotnet ef dbcontext scaffold [CONNECTION STRING] [DATABASE DRIVER] [FLAGS]

The command contains two unfamiliar terms—dbcontext and scaffold:

  • dbcontext refers to the creation of a class of type DbContext. A dbcontext is the main class we use to set up our database connection in the code.

  • scaffold instructs Entity Framework Core to create models for all database entities in the database we are connected to. Much like real-life scaffolding, it creates a sort of wrap around the original item (a house or a building) that we can use to modify the said item. In our case, it puts a scaffold around the deployed SQL database.

We can use flags to specify the folder of the generated models and dbContext. We’ll save these into a dedicated folder as follows, to avoid having a bunch of model files in our project root folder:

> dotnet ef dbcontext scaffold
 "Server=tcp:codelikeacsharppro.database.windows.net,1433;Initial 
 Catalog=FlyingDutchmanAirlines;Persist Security Info=False;User  
 Id=dev;Password=FlyingDutchmanAirlines1972!;
 MultipleActiveResultSets=False;Encrypt=True;
 TrustServerCertificate=False;Connection Timeout=30;" 
 Microsoft.EntityFrameworkCore.SqlServer -–context-dir DatabaseLayer 
 –-output-dir DatabaseLayer/Models

If you run into issues running the command, please double-check all spaces, line breaks (there should be none), and flags. The command starts by building the current project. Then, it tries to connect to the database with the given connection string. Finally, it generates the dbContext class (FlyingDutchmanAirlinesContext.cs) and the appropriate models. Let’s examine the created FlyingDutchmanAirlinesContext class. A generated DatabaseContext has the following four major pieces:

  • Constructors

  • Collections of type DbSet containing entities

  • Configuration methods

  • Model-creation options

But before we look at these items, there is something peculiar in the class declaration:

public partial class FlyingDutchmanAirlinesContext : DbContext

What is this partial business?

  

Partial classes

You can use the partial keyword to break up the definition of a class across multiple files. In general, it creates a bit of a readability mess but can be useful. Partial classes are especially useful for automatic code generators (like Entity Framework Core), because the generator can put code in partial classes, thus allowing the developer to enrich the class’s implementation.

That said, we know we are not going to be providing more functionality to FlyingDutchmanAirlinesContext in a different file, so we can remove the partial keyword from the class. This is a good example of making sure that the code that is automatically generated is exactly how you want it. Just because a generator or template did it a certain way does not mean you cannot edit it.

public class FlyingDutchmanAirlinesContext : DbContext

Note that this change is optional.

If you look at the generated class, you’ll notice it has two different constructors. By default, in C#, if you do not provide a constructor, the compiler generates a parameterless constructor for you under the hood. This constructor is called the default constructor, or the implicit constructor. C# creates the default constructor whenever there is no explicit constructor so you can instantiate a new instance of the said class.

As seen in listing 5.4, both constructors can create an instance of FlyingDutchmanAirlinesContext. In the case of FlyingDutchmanAirlines, you can create a new instance with or without passing in an instance of type DbContextOptions. If you do pass that instance into the constructor, it invokes the constructor of its base class (DbContext in this case).

Listing 5.4 FlyingDutchmanAirlinesContext constructors

public FlyingDutchmanAirlinesContext() { }                        
 
public FlyingDutchmanAirlinesContext(DbContextOptions
 <FlyingDutchmanAirlinesContext> options) : base(options) { }   

An explicit default constructor

An overloaded constructor with a parameter that calls the base constructor

For more information on method and constructor overloading, see chapter 4.

5.3.2 DbSet and the Entity Framework Core workflow

In this section, we’ll discuss the DbSet type as well as the general workflow when using Entity Framework Core. Looking past the constructors, we see four collections of type DbSet, each holding one of our database models. The DbSet types are collections that we consider part of the internals of EF Core. Entity Framework Core uses the DbSet<Entity> collections to store and maintain an accurate copy of the database tables and their contents.

We also see a familiar concept: auto-properties. The collections are public, but they are also virtual, as shown next:

public virtual DbSet<Airport> Airport { get; set; }
public virtual DbSet<Booking> Booking { get; set; }
public virtual DbSet<Customer> Customer { get; set;}
public virtual DbSet<Flight> Flight { get; set; }

When you declare something virtual, you tell the compiler that you allow the property or method to be overridden in a derived class. If you do not declare something as virtual, you cannot override it.

  

Hiding parent properties and methods/sealing classes

In a world where you have a class that implements a base class containing properties or methods not declared as virtual, we cannot override the implementation of said properties and methods. What to do? Well, we have a workaround for this problem. We can “hide” the properties and methods of the parent by inserting the new keyword into the method or property signature, as shown in the next code. This keyword tells the compiler that, instead of providing a new implementation to the existing parent method, we just want to call this brand-new method that happens to have the same name. In practice, it allows you to “override” nonvirtual properties and methods.

public new void ThisMethodWasNotVirtual() {}

Be warned, however, that hiding is frowned on. In an ideal world, the developer of the original class has the know-how to predict which properties and methods to declare as virtual. If you need to do things outside of the system (using a workaround to perform unexpected and uncontrolled overrides), think twice before hitting that commit code button. The original developer did not expect you to do this, nor did they want you to override it in the first place (if they did, they would have provided you with a virtual property or method).

From the perspective of the developer of the base class, how can you prevent your nonvirtual methods and properties from being hidden in a derived class? Unfortunately, there is no atomic way of specifying this per property or method. We do have, however, a more nuclear option: the sealed keyword. You can declare a class sealed with the sealed keyword, as shown next. This is a good option to safeguard your classes because you cannot create a derived class based on a sealed class. Because inheritance is off the table, so is overriding or hiding anything.

public sealed class Pinniped(){}

Like many other ORM tools, Entity Framework Core often behaves unintuitively at first. All operations you would normally make directly against the database are done against an in-memory model before they are saved to the database. To do this, Entity Framework Core stores most available database records in the DbSet. This means that if you have added a Flight entity with a primary key of 192 in the database, you also have that particular entity loaded into memory during runtime. Having access to the database contents from memory at runtime allows you to easily manipulate objects and abstract away that you are using a database at all. The drawback is performance. Keeping lots of records in memory can become quite the resource hog, depending on how large your database is (or becomes). As shown in figure 5.7, the normal workflow for operating on an entity through Entity Framework Core follows:

  1. Query the appropriate DbSet for the object you want to manipulate (not needed for INSERT/ADD operations).

  2. Manipulate the object (not needed for READ operations).

  3. Change the DbSet appropriately (not needed for READ operations).

Figure 5.7 The three general steps to make changes to a database through Entity Framework Core: query the DbSet, manipulate the object, and then change the DbSet.

It is good to keep in mind that just because changes have been made in a DbSet, they are not necessarily made in the database yet. Entity Framework Core still needs to commit these changes to the database, and we’ll explore how to do that further in this chapter.

5.3.3 Configuration methods and environment variables

The third building block of the FlyingDutchmanAirlinesContext class comprises two configuration methods: OnConfiguring and OnModelCreating, shown in the next code. OnConfiguring is called on the configuration of the DbContext, which is done automatically at launch, whereas OnModelCreating is called during model creation (at runtime, during launch).

protected override void OnConfiguring(DbContextOptionsBuilder 
  optionsBuilder) {
  if (!optionsBuilder.IsConfigured) {
    optionsBuilder.UseSqlServer(
 "Server=tcp:codelikeacsharppro.database.windows.net,1433;Initial 
 Catalog=FlyingDutchmanAirlines;Persist Security Info=False;User 
 ID=dev;Password=FlyingDutchmanAirlines1972!;
 MultipleActiveResultSets=False; 
 Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;");
  }
}

The OnConfiguring method takes in an argument of type DbContextOptionsBuilder. The OnConfiguring method is called by the runtime automatically on the configuration of the DbContext and uses dependency injection to provide the DbContextOptionsBuilder. Here, we should configure any settings related to how we connect to the database. Therefore, we need to provide a connection string.

But, unfortunately, the hardcoded connection string rears its ugly head once more. Surely there must be a better way to do this. I propose we use environment variables for this. An environment variable is a key-value pair, {K, V }, which we set at the operating system level. We can retrieve environment variables at run time, making them excellent for providing variables that either change per system or deployment or values that we do not want hardcoded in our codebase.

NOTE Environment variables are often used for web services deployed through containerized orchestration systems such as Kubernetes. If you do not want to (or cannot) set an environment variable on the operating system level, you can instead use cloud solutions such as Azure Key Vault and Amazon AWS Key Management Service. For more information on Kubernetes, see Ashley David’s Bootstrapping Microservices with Docker, Kubernetes, and Terraform (Manning, 2021) or Marko Lukša’s Kubernetes in Action (2nd edition; Manning, 2021).

Every operating system does environment variables slightly differently—we’ll discuss the practical differences between Windows and macOS in a moment. The way we retrieve an environment variable in C# does not change based on the operating system, however. In the System.IO namespace is a method called GetEnvironmentVariable that we can use for that exact purpose, as shown here:

Environment.GetEnvironmentVariable([ENVIRONMENT VARIABLE KEY]);

You just pass it in the key of the environment variable you want to retrieve (ENVIRONMENT VARIABLE KEY), and the method does so for you. If the environment variable does not exist, it returns a null value without throwing an exception, so you need to do some validation based on that null value. What would your environment variable look like? Because it is a key-value pair, and because environment variables cannot contain any spaces, you can do something like {FlyingDutchmanAirlines_Database_Connection_ String, [Connection String]}.

TIP Because environment variables are system wide, you cannot have environment variables with duplicate keys. Keep this in mind when choosing a value for the key.

5.3.4 Setting an environment variable on Windows

The process of setting environment variables differs slightly from operating system to operating system. In Windows, you set an environment variable through the Windows command line, using the setx command, followed by the desired key-value pair, as follows:

> setx [KEY] [VALUE]
> setx FlyingDutchmanAirlines_Database_Connection_String 
 "Server=tcp:codelikeacsharppro.database.windows.net,1433;Initial 
 Catalog=FlyingDutchmanAirlines;Persist Security Info=False;User 
 ID=dev;Password=FlyingDutchmanAirlines1972!;
 MultipleActiveResultSets=False;Encrypt=True;
 TrustServerCertificate=False;Connection Timeout=30;"

If successful, the command line reports that the value was saved successfully (SUCCESS: Specified value was saved.). To verify that the environment variable was saved, launch a new command line (newly set environment variables do not show up in active command-line sessions), and run the echo command for the environment variable. If you do not see the environment variable show up, as shown next, you may have to reboot your machine:

> echo %FlyingDutchmanAirlines_Database_Connection_String%

If everything went all right, the echo command should return the value of the environment variable (in this case, our connection string). We can now use this environment variable in our service!

5.3.5 Setting an environment variable on macOS

Like Windows, we use a command-line environment to set environment variables on macOS: the macOS terminal. Setting an environment variable is just as easy on macOS as it is on Windows, as shown here:

> export [KEY] [VALUE]
> export FlyingDutchmanAirlines_Database_Connection_String 
 "Server=tcp:codelikeacsharppro.database.windows.net,1433;Initial 
 Catalog=FlyingDutchmanAirlines;Persist Security Info=False;User 
 ID=dev;Password=FlyingDutchmanAirlines1972!;
 MultipleActiveResultSets=False;Encrypt=True;
 TrustServerCertificate=False;Connection Timeout=30;"

And you can verify by using echo on macOS as well, like so:

> echo $FlyingDutchmanAirlines_Database_Connection_String

On macOS, things are somewhat trickier when we run the service and try to grab the environment variables when debugging a codebase through Visual Studio. In macOS, environment variables defined through the command line do not automatically become available to GUI applications such as Visual Studio. The workaround is to launch Visual Studio through the macOS terminal or to add the environment variables in Visual Studio as part of the runtime configurations.

5.3.6 Retrieving environment variables at run time in your code

Having set the environment variable, we can now grab it in our code. We want to grab it in the OnConfigure method instead of hardcoding the connection string. We can use the Environment.GetEnvironmentVariable method for this. Because the Environment.GetEnvironmentVariable returns a null value if it cannot find the environment variable, we use the null coalescing operator (??) to set it to an empty string in that case, as follows:

protected override void OnConfiguring(DbContextOptionsBuilder
 optionsBuilder)  {
  if(!optionsBuilder.IsConfigured) {
    string connectionString = Environment.GetEnvironmentVariable(
 "FlyingDutchmanAirlines_Database_Connection_String") 
 ?? string.Empty;
    optionsBuilder.UseSqlServer(connectionString);
  }
}

We could have handled the null case in a couple of different ways (most notably by using either a conditional or by inlining the GetEnvironmentVariable call along with the null coalescing operator into the UseSqlServer method), but this is my preferred way. It is readable yet succinct. By doing this little trick, we increased the security of our application tenfold, especially when you consider the problems caused by a hard-coded connection string committed to a source control management system.

The remaining code we have not touched on yet in the FlyingDutchmanAirlinesContext are the OnModelCreating methods, shown in the next listing.

Listing 5.5 FlyingDutchmanAirlinesContext OnModelCreating

protected override void OnModelCreating(ModelBuilder modelBuilder) {   
  modelBuilder.Entity<Airport>(entity => {                             
    entity.Property(e => e.AirportId)                                  
      .HasColumnName("AirportID")                                      
      .ValueGeneratedNever();                                          
 
    entity.Property(e => e.City)                                       
      .IsRequired()                                                    
      .HasMaxLength(50)                                                
      .IsUnicode(false)                                                
 
    entity.Property(e => e.Iata)                                       
      .IsRequired()                                                    
      .HasColumnName("IATA")                                           
      .HasMaxLength(3)                                                 
      .IsUnicode(false)                                                
    });                                                                
 
  modelBuilder.Entity<Booking>(entity =>  {                            
    entity.Property(e => e.BookingId).HasColumnName("BookingID");      
    
    entity.Property(e => e.CustomerId).HasColumnName("CustomerID");    
    
    entity.HasOne(d => d.Customer)                                     
      .WithMany(p => p.Booking)                                        
      .HasForeignKey(d => d.CustomerId)                                
      .HasConstraingName("FK__Booking__Custome_71D1E811");             
    
    entity.HasOne(d => d.FlightNumberNavigation)                       
      .WithMany(p => p.Booking)                                        
      .HasForeignKey(d => d.FlightNumber)                              
      .OnDelete(DeleteBehavior.ClientSetNull)                          
      .HasConstraintName(“FK__Booking__FlightN__4F7CD00D”);            
  });                                                                  
  modelBuilder.Entity<Customer>(entity => {                            
    entity.Property(e => e.CustomerId)                                 
      .HasColumnName("CustomerID")                                     
 
      entity.Property(e => e.Name)                                     
      .IsRequired()                                                    
      .HasMaxLength(50)                                                
      .IsUnicode(false)                                                
    });                                                                
  
  modelBuilder.Entity<Flight>(entity => {                              
    entity.HasKey(e => e.FlightNumber);                                
         
    entity.Property(e => e.FlightNumber).ValueGeneratedNever();        
 
    entity.HasOne(d => d.DestinationNavigation)                        
      .WithMany(p => p.FlightDestinationNavigation)                    
      .HasForeignKey(d => d.Destination)                               
      .OnDelete(DeleteBehavior.ClientSetNull)                          
      .HasConstraintName("FK_Flight_AirportDestination");              
 
    entity.HasOne(d => d.OriginNavigation)                             
      .WithMany(p => p.FlightOriginNavigation)                         
      .HasForeignKey(d => d.Origin)                                    
      .OnDelete(DeleteBehavior.ClientSetNull);                         
    });                                                                
 
    OnModelCreatingPartial(modelBuilder);                              
}
 
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);        

Overrides the base’s OnModelCreating method

Prepares the EF Core to use the Airport model

Prepares the EF Core to use the Booking model

Prepares the EF Core to use the Customer model

Prepares the EF Core to use the Flight model

Calls the partial OnModelCreatingPartial method

Defines the partial OnModelCreatingPartial method

Note that the exact constraint names may differ on your system, because they are autogenerated. The OnModelCreating method sets up the entities internally for Entity Framework Core, along with the key constraints defined in the database schema. This allows us to operate on the entities without directly messing with the database (which is the whole idea of Entity Framework Core). A generated method (and a call to it) is also called OnModelCreatingPartial. The Entity Framework Core console toolset generated the OnModelCreatingPartial method, so you can execute additional logic as part of the model-creation process. We are not going to do that, so we can remove the OnModelCreatingPartial method and the call to it. Do be aware that if you have to rerun the reverse-engineering process (or any other code-generator tool), your changes will be overwritten again.

Exercises

Exercise 5.1

If we want to prevent somebody from deriving from a class, what keyword do we need to attach to the class?

a. Virtual

b. Sealed

c. Protected

Exercise 5.2

If we want to allow somebody to override a property or method, what keyword do we attach?

a. Virtual

b. Sealed

c. Protected

Exercise 5.3

Fill in the blanks: “A __________ is the underlying process that runs the web service. It, in turn, lives inside the __________.

a. host

b. Tomcat

c. JVM

d. CLR

Exercise 5.4

True or false? When using a Startup class, you need to register it with the Host.

Exercise 5.5

Try it yourself: Write an expression-body-style method that accepts two integers and returns their product. This should be a one-line solution. Hint: Think about lambda.

Exercise 5.6

Within the context of a repository/service pattern, how many controller, service, and repository layers should there be?

Summary

  • We can create .NET 5 solutions and projects by using predefined templates in the command line such as console and mvc. Templates are ways to easily create common flavors of solutions and projects.

  • A restore is an operation that gets all necessary dependencies for a project to compile.

  • We can add a project to a solution by using the dotnet sln [SOLUTION] add [PROJECT] command.

  • A Host is a process living inside the CLR that runs a web application, providing an interface between the CLR and the user.

  • Methods that just return the value of an expression can be written succinctly using a syntax similar to lambda expressions. This is called an expression-bodied method and can make our code more readable.

  • In a Startup class, we can set up routes and allow for the use of controllers and endpoints. This is important for MVC web applications because it allows us to call endpoints and use the concept of controllers.

  • The repository/service pattern comprises multiple repositories, services, and controllers (one per entity). This easy-to-follow paradigm helps us control the flow of data.

  • Entity Framework Core is a powerful object-relational mapping tool that can reverse-engineer deployed databases by scaffolding them. This saves the developer a lot of time and allows for a near-perfect isomorphic relationship between the database and the codebase.

  • Use the partial keyword to define classes and methods that have their implementation spread across multiple fields. The partial keyword is often used by automatic code generators.

  • When declaring something as virtual, you say that this property, field, or method can be overridden safely. This is useful when balancing the needs for the extensibility and the sanctity of your codebase.

  • You can “hide” nonvirtual properties and methods by adding the new keyword to a method or property signature.

  • When a class is sealed, you cannot inherit from it. In effect, sealing a class stops any class from deriving from it. Sealing classes becomes useful when you know for a fact that your class is the lowest level of inheritance there is and you want to prevent tampering with your code.

  • Environment variables are key-value pairs that can be set in an operating system. They can store sensitive data such as connection strings or passwords.


1.Installation instructions for Visual Studio can be found in appendix C. If you want to learn more about Visual Studio, see Bruce Johnson’s Professional Visual Studio 2017 (Wrox, 2017) and Johnson’s Essential Visual Studio 2019 (Apress, 2020). Disclaimer: The author was the technical reviewer for Essential Visual Studio 2019: Boosting Development Productivity with Containers, Git, and Azure Tools.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.19.29.89