Migrate Breweries to a vertical slice (Features.Breweries)

Moves brewery CRUD from the Service.Breweries/Infrastructure.Repository
layered split into a single Features.Breweries project: MediatR
commands/queries replace BreweryService, and the repository moves in
alongside its own controller. Extracts Infrastructure.Sql out of
Infrastructure.Repository (just the generic ADO.NET connection/base-repo
plumbing) since Breweries no longer needs the rest of that project, while
Auth and UserAccount repos still do until their own migration.

Also fixes the API.Core and Infrastructure.Repository.Tests Dockerfiles,
which were restoring against COPY destinations that didn't match the
projects' actual relative paths (silently working only because of the
COPY . . that followed); both now restore against Core.slnx with correctly
nested paths, verified with a real docker build.
This commit is contained in:
Aaron Po
2026-06-19 23:54:25 -04:00
parent 60a7b790d4
commit 34ba7e8271
46 changed files with 803 additions and 789 deletions

View File

@@ -0,0 +1,69 @@
using System.Data.Common;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
namespace Infrastructure.Sql;
/// <summary>
/// Default <see cref="ISqlConnectionFactory"/> implementation that creates SQL Server connections,
/// resolving the connection string from environment variables or application configuration.
/// </summary>
/// <param name="configuration">The application configuration, used as a fallback source for the connection string.</param>
public class DefaultSqlConnectionFactory(IConfiguration configuration)
: ISqlConnectionFactory
{
private readonly string _connectionString = GetConnectionString(
configuration
);
/// <summary>
/// Resolves the SQL Server connection string, preferring (in order): the <c>DB_CONNECTION_STRING</c>
/// environment variable, a connection string built from individual <c>DB_*</c> environment variables
/// via <see cref="SqlConnectionStringHelper.BuildConnectionString"/>, and finally the <c>"Default"</c>
/// connection string from <paramref name="configuration"/>.
/// </summary>
/// <param name="configuration">The application configuration to fall back to.</param>
/// <returns>The resolved SQL Server connection string.</returns>
/// <exception cref="InvalidOperationException">
/// Thrown when no connection string can be resolved from any of the supported sources.
/// </exception>
private static string GetConnectionString(IConfiguration configuration)
{
// Check for full connection string first
var fullConnectionString = Environment.GetEnvironmentVariable(
"DB_CONNECTION_STRING"
);
if (!string.IsNullOrEmpty(fullConnectionString))
{
return fullConnectionString;
}
// Try to build from individual environment variables (preferred method for Docker)
try
{
return SqlConnectionStringHelper.BuildConnectionString();
}
catch (InvalidOperationException)
{
// Fall back to configuration-based connection string if env vars are not set
var connString = configuration.GetConnectionString("Default");
if (!string.IsNullOrEmpty(connString))
{
return connString;
}
throw new InvalidOperationException(
"Database connection string not configured. Set DB_CONNECTION_STRING or DB_SERVER, DB_NAME, DB_USER, DB_PASSWORD env vars or ConnectionStrings:Default."
);
}
}
/// <summary>
/// Creates a new, unopened <see cref="SqlConnection"/> using the resolved connection string.
/// </summary>
/// <returns>A new <see cref="SqlConnection"/> instance.</returns>
public DbConnection CreateConnection()
{
return new SqlConnection(_connectionString);
}
}

View File

@@ -0,0 +1,15 @@
using System.Data.Common;
namespace Infrastructure.Sql;
/// <summary>
/// Factory for creating database connections used by repositories.
/// </summary>
public interface ISqlConnectionFactory
{
/// <summary>
/// Creates a new, unopened database connection.
/// </summary>
/// <returns>A new <see cref="DbConnection"/> instance.</returns>
DbConnection CreateConnection();
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Infrastructure.Sql</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
<PackageReference
Include="Microsoft.SqlServer.Types"
Version="160.1000.6"
/>
<PackageReference Include="System.Data.SqlClient" Version="4.9.0" />
<PackageReference
Include="Microsoft.Extensions.Configuration.Abstractions"
Version="9.0.0"
/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,32 @@
using System.Data.Common;
namespace Infrastructure.Sql;
/// <summary>
/// Base class for ADO.NET-based repositories, providing shared connection creation and
/// entity-mapping infrastructure for derived repository implementations.
/// </summary>
/// <typeparam name="T">The entity type managed by the repository.</typeparam>
/// <param name="connectionFactory">The factory used to create database connections.</param>
public abstract class Repository<T>(ISqlConnectionFactory connectionFactory)
where T : class
{
/// <summary>
/// Creates and opens a new database connection using the configured <see cref="ISqlConnectionFactory"/>.
/// </summary>
/// <returns>An open <see cref="DbConnection"/> ready for use.</returns>
/// <exception cref="DbException">Thrown when the connection cannot be opened.</exception>
protected async Task<DbConnection> CreateConnection()
{
var connection = connectionFactory.CreateConnection();
await connection.OpenAsync();
return connection;
}
/// <summary>
/// Maps the current row of a data reader to an instance of <typeparamref name="T"/>.
/// </summary>
/// <param name="reader">The data reader positioned on the row to map.</param>
/// <returns>The mapped entity instance.</returns>
protected abstract T MapToEntity(DbDataReader reader);
}

View File

@@ -0,0 +1,64 @@
using Microsoft.Data.SqlClient;
namespace Infrastructure.Sql;
/// <summary>
/// Helper for building SQL Server connection strings from environment variables.
/// </summary>
public static class SqlConnectionStringHelper
{
/// <summary>
/// Builds a SQL Server connection string from environment variables.
/// Expects DB_SERVER, DB_NAME, DB_USER, DB_PASSWORD, and DB_TRUST_SERVER_CERTIFICATE.
/// </summary>
/// <param name="databaseName">Optional override for the database name. If null, uses DB_NAME env var.</param>
/// <returns>A properly formatted SQL Server connection string.</returns>
public static string BuildConnectionString(string? databaseName = null)
{
var server =
Environment.GetEnvironmentVariable("DB_SERVER")
?? throw new InvalidOperationException(
"DB_SERVER environment variable is not set"
);
var dbName =
databaseName
?? Environment.GetEnvironmentVariable("DB_NAME")
?? throw new InvalidOperationException(
"DB_NAME environment variable is not set"
);
var user =
Environment.GetEnvironmentVariable("DB_USER")
?? throw new InvalidOperationException(
"DB_USER environment variable is not set"
);
var password =
Environment.GetEnvironmentVariable("DB_PASSWORD")
?? throw new InvalidOperationException(
"DB_PASSWORD environment variable is not set"
);
var builder = new SqlConnectionStringBuilder
{
DataSource = server,
InitialCatalog = dbName,
UserID = user,
Password = password,
TrustServerCertificate = true,
Encrypt = true,
};
return builder.ConnectionString;
}
/// <summary>
/// Builds a connection string to the master database using environment variables.
/// </summary>
/// <returns>A connection string for the master database.</returns>
public static string BuildMasterConnectionString()
{
return BuildConnectionString("master");
}
}