Skip to content

Commit c72a7c6

Browse files
Vertical Slice Architecture: rebuild the sample as an event ticketing API on net10.0
Replace the net8.0 games-and-consoles sample (MediatR 12.2.0, AutoMapper 13.0.1, EF Core InMemory, Swashbuckle, a shared ServiceManager) with the article's build: one minimal API project with three feature slices, an Event entity that guards its capacity rule, a Result type mapped to problem details, built-in validation, and EF Core SQLite 10.0.12. Tests run the slices against SQLite in memory and an architecture test keeps features from depending on each other. Classic EventTicketing.sln for the CI, launch profile on port 5000 so the .http file works from an IDE, and a folder .gitignore for the SQLite file. Removes the NU1903 advisory on AutoMapper 13.0.1 and the nullability warnings.
1 parent 7699766 commit c72a7c6

47 files changed

Lines changed: 489 additions & 1012 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
tickets.db
2+
tickets.db-shm
3+
tickets.db-wal
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net10.0</TargetFramework>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<Nullable>enable</Nullable>
7+
<IsPackable>false</IsPackable>
8+
<IsTestProject>true</IsTestProject>
9+
</PropertyGroup>
10+
11+
<ItemGroup>
12+
<Using Include="Xunit" />
13+
</ItemGroup>
14+
15+
<ItemGroup>
16+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
17+
<PackageReference Include="NetArchTest.Rules" Version="1.3.2" />
18+
<PackageReference Include="xunit.v3.mtp-off" Version="4.0.1" />
19+
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0" />
20+
</ItemGroup>
21+
22+
<ItemGroup>
23+
<ProjectReference Include="..\EventTicketing\EventTicketing.csproj" />
24+
</ItemGroup>
25+
26+
</Project>
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
using EventTicketing.Data;
2+
using EventTicketing.Features.Reservations;
3+
using Microsoft.Data.Sqlite;
4+
using Microsoft.EntityFrameworkCore;
5+
6+
namespace EventTicketing.Tests;
7+
8+
public sealed class ReserveTicketsTests : IDisposable
9+
{
10+
private readonly SqliteConnection _connection = new("Data Source=:memory:");
11+
12+
public ReserveTicketsTests() => _connection.Open();
13+
14+
public void Dispose() => _connection.Dispose();
15+
16+
private TicketingDbContext CreateDbContext()
17+
{
18+
var options = new DbContextOptionsBuilder<TicketingDbContext>()
19+
.UseSqlite(_connection)
20+
.Options;
21+
22+
var dbContext = new TicketingDbContext(options);
23+
dbContext.Database.EnsureCreated();
24+
25+
return dbContext;
26+
}
27+
28+
[Fact]
29+
public async Task HandleAsync_WhenTicketsAreAvailable_SavesTheReservation()
30+
{
31+
var handler = new ReserveTickets.Handler(CreateDbContext());
32+
33+
var result = await handler.HandleAsync(eventId: 1, quantity: 3, TestContext.Current.CancellationToken);
34+
35+
Assert.True(result.IsSuccess);
36+
Assert.Equal(97, result.Value.TicketsLeft);
37+
38+
var saved = await CreateDbContext().Events.SingleAsync(e => e.Id == 1, TestContext.Current.CancellationToken);
39+
Assert.Equal(3, saved.TicketsSold);
40+
}
41+
42+
[Fact]
43+
public async Task HandleAsync_WhenTheEventIsSoldOut_SavesNothing()
44+
{
45+
var handler = new ReserveTickets.Handler(CreateDbContext());
46+
47+
var result = await handler.HandleAsync(eventId: 2, quantity: 3, TestContext.Current.CancellationToken);
48+
49+
Assert.False(result.IsSuccess);
50+
Assert.Equal("Event.SoldOut", result.Error!.Code);
51+
52+
var saved = await CreateDbContext().Events.SingleAsync(e => e.Id == 2, TestContext.Current.CancellationToken);
53+
Assert.Equal(0, saved.TicketsSold);
54+
}
55+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using EventTicketing.Features.Reservations;
2+
using NetArchTest.Rules;
3+
4+
namespace EventTicketing.Tests;
5+
6+
public class SliceTests
7+
{
8+
[Fact]
9+
public void Features_DoNotDependOnEachOther()
10+
{
11+
var assembly = typeof(ReserveTickets).Assembly;
12+
13+
var features = assembly.GetTypes()
14+
.Select(t => t.Namespace)
15+
.OfType<string>()
16+
.Where(ns => ns.StartsWith("EventTicketing.Features."))
17+
.Distinct()
18+
.ToArray();
19+
20+
foreach (var feature in features)
21+
{
22+
var otherFeatures = features.Where(f => f != feature).ToArray();
23+
24+
var result = Types.InAssembly(assembly)
25+
.That()
26+
.ResideInNamespace(feature)
27+
.ShouldNot()
28+
.HaveDependencyOnAny(otherFeatures)
29+
.GetResult();
30+
31+
Assert.True(result.IsSuccessful,
32+
$"{feature} depends on another feature: " + string.Join(", ", result.FailingTypeNames ?? []));
33+
}
34+
}
35+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
2+
Microsoft Visual Studio Solution File, Format Version 12.00
3+
# Visual Studio Version 17
4+
VisualStudioVersion = 17.0.31903.59
5+
MinimumVisualStudioVersion = 10.0.40219.1
6+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventTicketing", "EventTicketing\EventTicketing.csproj", "{5FAF63F8-89C3-4EEE-A3A0-DCB365CA69B6}"
7+
EndProject
8+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventTicketing.Tests", "EventTicketing.Tests\EventTicketing.Tests.csproj", "{4B09EE23-0672-4FE0-8078-430028A5351E}"
9+
EndProject
10+
Global
11+
GlobalSection(SolutionConfigurationPlatforms) = preSolution
12+
Debug|Any CPU = Debug|Any CPU
13+
Debug|x64 = Debug|x64
14+
Debug|x86 = Debug|x86
15+
Release|Any CPU = Release|Any CPU
16+
Release|x64 = Release|x64
17+
Release|x86 = Release|x86
18+
EndGlobalSection
19+
GlobalSection(ProjectConfigurationPlatforms) = postSolution
20+
{5FAF63F8-89C3-4EEE-A3A0-DCB365CA69B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
21+
{5FAF63F8-89C3-4EEE-A3A0-DCB365CA69B6}.Debug|Any CPU.Build.0 = Debug|Any CPU
22+
{5FAF63F8-89C3-4EEE-A3A0-DCB365CA69B6}.Debug|x64.ActiveCfg = Debug|Any CPU
23+
{5FAF63F8-89C3-4EEE-A3A0-DCB365CA69B6}.Debug|x64.Build.0 = Debug|Any CPU
24+
{5FAF63F8-89C3-4EEE-A3A0-DCB365CA69B6}.Debug|x86.ActiveCfg = Debug|Any CPU
25+
{5FAF63F8-89C3-4EEE-A3A0-DCB365CA69B6}.Debug|x86.Build.0 = Debug|Any CPU
26+
{5FAF63F8-89C3-4EEE-A3A0-DCB365CA69B6}.Release|Any CPU.ActiveCfg = Release|Any CPU
27+
{5FAF63F8-89C3-4EEE-A3A0-DCB365CA69B6}.Release|Any CPU.Build.0 = Release|Any CPU
28+
{5FAF63F8-89C3-4EEE-A3A0-DCB365CA69B6}.Release|x64.ActiveCfg = Release|Any CPU
29+
{5FAF63F8-89C3-4EEE-A3A0-DCB365CA69B6}.Release|x64.Build.0 = Release|Any CPU
30+
{5FAF63F8-89C3-4EEE-A3A0-DCB365CA69B6}.Release|x86.ActiveCfg = Release|Any CPU
31+
{5FAF63F8-89C3-4EEE-A3A0-DCB365CA69B6}.Release|x86.Build.0 = Release|Any CPU
32+
{4B09EE23-0672-4FE0-8078-430028A5351E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
33+
{4B09EE23-0672-4FE0-8078-430028A5351E}.Debug|Any CPU.Build.0 = Debug|Any CPU
34+
{4B09EE23-0672-4FE0-8078-430028A5351E}.Debug|x64.ActiveCfg = Debug|Any CPU
35+
{4B09EE23-0672-4FE0-8078-430028A5351E}.Debug|x64.Build.0 = Debug|Any CPU
36+
{4B09EE23-0672-4FE0-8078-430028A5351E}.Debug|x86.ActiveCfg = Debug|Any CPU
37+
{4B09EE23-0672-4FE0-8078-430028A5351E}.Debug|x86.Build.0 = Debug|Any CPU
38+
{4B09EE23-0672-4FE0-8078-430028A5351E}.Release|Any CPU.ActiveCfg = Release|Any CPU
39+
{4B09EE23-0672-4FE0-8078-430028A5351E}.Release|Any CPU.Build.0 = Release|Any CPU
40+
{4B09EE23-0672-4FE0-8078-430028A5351E}.Release|x64.ActiveCfg = Release|Any CPU
41+
{4B09EE23-0672-4FE0-8078-430028A5351E}.Release|x64.Build.0 = Release|Any CPU
42+
{4B09EE23-0672-4FE0-8078-430028A5351E}.Release|x86.ActiveCfg = Release|Any CPU
43+
{4B09EE23-0672-4FE0-8078-430028A5351E}.Release|x86.Build.0 = Release|Any CPU
44+
EndGlobalSection
45+
GlobalSection(SolutionProperties) = preSolution
46+
HideSolutionNode = FALSE
47+
EndGlobalSection
48+
EndGlobal
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
using EventTicketing.Domain;
2+
using Microsoft.EntityFrameworkCore;
3+
4+
namespace EventTicketing.Data;
5+
6+
public sealed class TicketingDbContext(DbContextOptions<TicketingDbContext> options)
7+
: DbContext(options)
8+
{
9+
public DbSet<Event> Events => Set<Event>();
10+
11+
protected override void OnModelCreating(ModelBuilder modelBuilder)
12+
{
13+
modelBuilder.Entity<Event>(builder =>
14+
{
15+
builder.Property(e => e.Name).HasMaxLength(200);
16+
17+
builder.HasData(
18+
new { Id = 1, Name = "Vertical Slice Live", Capacity = 100, TicketsSold = 0 },
19+
new { Id = 2, Name = "Tiny Jazz Club Night", Capacity = 2, TicketsSold = 0 });
20+
});
21+
}
22+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
namespace EventTicketing.Domain;
2+
3+
public sealed class Event
4+
{
5+
private Event(string name, int capacity)
6+
{
7+
Name = name;
8+
Capacity = capacity;
9+
}
10+
11+
public int Id { get; private set; }
12+
public string Name { get; private set; }
13+
public int Capacity { get; private set; }
14+
public int TicketsSold { get; private set; }
15+
16+
public int TicketsLeft => Capacity - TicketsSold;
17+
18+
public static Event Create(string name, int capacity)
19+
{
20+
ArgumentException.ThrowIfNullOrWhiteSpace(name);
21+
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity);
22+
23+
return new Event(name, capacity);
24+
}
25+
26+
public Result Reserve(int quantity)
27+
{
28+
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);
29+
30+
if (quantity > TicketsLeft)
31+
return EventErrors.SoldOut(TicketsLeft, quantity);
32+
33+
TicketsSold += quantity;
34+
35+
return Result.Success();
36+
}
37+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
namespace EventTicketing.Domain;
2+
3+
public static class EventErrors
4+
{
5+
public static Error NotFound(int eventId) =>
6+
new("Event.NotFound", $"Event {eventId} was not found.", ErrorType.NotFound);
7+
8+
public static Error SoldOut(int ticketsLeft, int requested) =>
9+
new("Event.SoldOut", $"Only {ticketsLeft} tickets left, {requested} requested.", ErrorType.Conflict);
10+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
namespace EventTicketing.Domain;
2+
3+
public enum ErrorType
4+
{
5+
NotFound,
6+
Conflict
7+
}
8+
9+
public sealed record Error(string Code, string Description, ErrorType Type);
10+
11+
public class Result
12+
{
13+
protected Result(Error? error) => Error = error;
14+
15+
public Error? Error { get; }
16+
public bool IsSuccess => Error is null;
17+
18+
public static Result Success() => new(null);
19+
20+
public static implicit operator Result(Error error) => new(error);
21+
}
22+
23+
public sealed class Result<TValue> : Result
24+
{
25+
private readonly TValue? _value;
26+
27+
private Result(TValue? value, Error? error)
28+
: base(error) => _value = value;
29+
30+
public TValue Value => IsSuccess
31+
? _value!
32+
: throw new InvalidOperationException("A failed result has no value.");
33+
34+
public static implicit operator Result<TValue>(TValue value) =>
35+
new(value, null);
36+
37+
public static implicit operator Result<TValue>(Error error) =>
38+
new(default, error);
39+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<Project Sdk="Microsoft.NET.Sdk.Web">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net10.0</TargetFramework>
5+
<Nullable>enable</Nullable>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
</PropertyGroup>
8+
9+
<ItemGroup>
10+
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.12" />
11+
</ItemGroup>
12+
13+
</Project>

0 commit comments

Comments
 (0)