feat(backend): restructure API into layers with OpenAPI, versioning and error handling

Split the backend into API host, Domain, Application and Infrastructure projects. Add Swagger/OpenAPI per API version, Asp.Versioning (query/header), ProblemDetails with a global exception handler and SurrealQL migrations applied at startup (namespace/database ensured). Infrastructure owns the SurrealDB client, settings and DI.
This commit is contained in:
Rafał Tyński
2026-10-04 01:49:51 +02:00
parent 0a5ef715eb
commit b3756238a3
26 changed files with 437 additions and 63 deletions
@@ -0,0 +1,52 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using SurrealDb.Net;
using TarninaHub.Application.Abstractions;
using TarninaHub.Infrastructure.Persistence;
using TarninaHub.Infrastructure.Settings;
namespace TarninaHub.Infrastructure;
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration configuration)
{
services.Configure<SurrealDbSettings>(configuration.GetSection("SurrealDb"));
var settings = configuration.GetSection("SurrealDb").Get<SurrealDbSettings>()
?? new SurrealDbSettings();
var options = new SurrealDbOptions
{
Endpoint = SurrealDbEndpoint.Build(settings),
Namespace = settings.Namespace,
Database = settings.Database,
};
if (string.IsNullOrWhiteSpace(settings.RemoteUrl))
{
// Embedded RocksDB, persisted next to the running app (./data), never in memory.
var dataDirectory = Path.Combine(AppContext.BaseDirectory, settings.DataDirectory);
Directory.CreateDirectory(dataDirectory);
services
.AddSurreal(options, ServiceLifetime.Singleton)
.AddRocksDbProvider();
}
else
{
// Remote SurrealDB server on its own dedicated port (used for sync with the frontend).
options.Username = settings.Username;
options.Password = settings.Password;
services.AddSurreal(options, ServiceLifetime.Singleton);
}
services.AddSingleton<IStorageService, StorageService>();
services.AddSingleton<IMigrationRunner, MigrationRunner>();
return services;
}
}
@@ -0,0 +1,63 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SurrealDb.Net;
using TarninaHub.Application.Abstractions;
using TarninaHub.Infrastructure.Settings;
namespace TarninaHub.Infrastructure.Persistence;
public sealed class MigrationRunner : IMigrationRunner
{
private readonly ISurrealDbClient _client;
private readonly SurrealDbSettings _settings;
private readonly ILogger<MigrationRunner> _logger;
public MigrationRunner(
ISurrealDbClient client,
IOptions<SurrealDbSettings> settings,
ILogger<MigrationRunner> logger)
{
_client = client;
_settings = settings.Value;
_logger = logger;
}
public async Task ApplyAsync(CancellationToken cancellationToken)
{
await EnsureNamespaceAsync(cancellationToken);
var migrationsPath = Path.Combine(AppContext.BaseDirectory, _settings.MigrationsDirectory);
if (!Directory.Exists(migrationsPath))
{
_logger.LogWarning(
"Migrations directory '{MigrationsPath}' was not found; skipping migrations.",
migrationsPath);
return;
}
var files = Directory.GetFiles(migrationsPath, "*.surql")
.OrderBy(file => file, StringComparer.Ordinal);
foreach (var file in files)
{
var sql = await File.ReadAllTextAsync(file, cancellationToken);
await _client.RawQuery(sql, cancellationToken: cancellationToken);
_logger.LogInformation("Applied migration {Migration}.", Path.GetFileName(file));
}
}
private async Task EnsureNamespaceAsync(CancellationToken cancellationToken)
{
var sql =
$"DEFINE NAMESPACE IF NOT EXISTS {_settings.Namespace}; " +
$"USE NS {_settings.Namespace}; " +
$"DEFINE DATABASE IF NOT EXISTS {_settings.Database}; " +
$"USE DB {_settings.Database};";
await _client.RawQuery(sql, cancellationToken: cancellationToken);
}
}
@@ -0,0 +1,58 @@
using Microsoft.Extensions.Options;
using SurrealDb.Net;
using TarninaHub.Application.Abstractions;
using TarninaHub.Application.Dtos;
using TarninaHub.Infrastructure.Settings;
namespace TarninaHub.Infrastructure.Persistence;
public sealed class StorageService : IStorageService
{
private const string ProbeTable = "storage_probe";
private readonly ISurrealDbClient _client;
private readonly SurrealDbSettings _settings;
private readonly string _dataDirectory;
public StorageService(ISurrealDbClient client, IOptions<SurrealDbSettings> settings)
{
_client = client;
_settings = settings.Value;
_dataDirectory = Path.Combine(AppContext.BaseDirectory, _settings.DataDirectory);
}
public async Task InitializeAsync(CancellationToken cancellationToken)
{
await _client.RawQuery(
$"DEFINE TABLE IF NOT EXISTS {ProbeTable} SCHEMALESS;",
cancellationToken: cancellationToken);
}
public StorageInfo GetInfo()
{
var isRemote = !string.IsNullOrWhiteSpace(_settings.RemoteUrl);
return new StorageInfo(
isRemote ? "remote" : "rocksdb",
isRemote ? _settings.RemoteUrl! : _dataDirectory,
_settings.Namespace,
_settings.Database);
}
public async Task<int> GetProbeCountAsync(CancellationToken cancellationToken)
{
var probes = await _client.Select<StorageProbe>(ProbeTable, cancellationToken);
return probes.Count();
}
public Task<StorageProbe> AddProbeAsync(CancellationToken cancellationToken)
{
var probe = new StorageProbe
{
CreatedAt = DateTimeOffset.UtcNow,
};
return _client.Create(ProbeTable, probe, cancellationToken);
}
}
@@ -0,0 +1,21 @@
namespace TarninaHub.Infrastructure.Settings;
public static class SurrealDbEndpoint
{
/// <summary>
/// Builds the SurrealDB endpoint. Remote mode uses the configured URL;
/// embedded mode uses the RocksDB provider persisted to the data directory
/// (forward slashes, so the same endpoint works on Windows and Linux).
/// </summary>
public static string Build(SurrealDbSettings settings)
{
if (!string.IsNullOrWhiteSpace(settings.RemoteUrl))
{
return settings.RemoteUrl;
}
var dataDirectory = Path.Combine(AppContext.BaseDirectory, settings.DataDirectory);
return $"rocksdb://{dataDirectory.Replace('\\', '/')}";
}
}
@@ -0,0 +1,27 @@
namespace TarninaHub.Infrastructure.Settings;
public sealed class SurrealDbSettings
{
public string Namespace { get; set; } = "hubmi";
public string Database { get; set; } = "hubmi";
public string DataDirectory { get; set; } = "data";
/// <summary>
/// Directory (relative to the app base directory) with ordered *.surql
/// migration files applied at startup. Copied from backend/migrations.
/// </summary>
public string MigrationsDirectory { get; set; } = "migrations";
/// <summary>
/// When set (e.g. ws://127.0.0.1:8010/rpc), the backend connects to a
/// dedicated SurrealDB server instead of the embedded RocksDB engine.
/// The server must persist to ./data, not to memory.
/// </summary>
public string? RemoteUrl { get; set; }
public string? Username { get; set; }
public string? Password { get; set; }
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.7" />
<PackageReference Include="SurrealDb.Embedded.RocksDb" Version="1.0.0" />
<PackageReference Include="SurrealDb.Net" Version="1.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Application\TarninaHub.Application.csproj" />
<ProjectReference Include="..\Domain\TarninaHub.Domain.csproj" />
</ItemGroup>
</Project>