diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea5ebf9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,205 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.sln.docstates + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +x64/ +bld/ +[Bb]in/ +[Oo]bj/ + +# Roslyn cache directories +*.ide/ + +# AWS +*.aws-sam/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +#NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opensdf +*.sdf +*.cachefile + +# Visual Studio profiler +*.psess +*.vsp +*.vspx + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding addin-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +## TODO: Comment the next line if you want to checkin your +## web deploy settings but do note that will include unencrypted +## passwords +#*.pubxml + +# NuGet Packages Directory +#packages/* +## TODO: If the tool you use requires repositories.config +## uncomment the next line +#!packages/repositories.config + +# Enable "build/" folder in the NuGet Packages folder since +# NuGet packages use it for MSBuild targets. +# This line needs to be after the ignore of the build folder +# (and the packages folder if the line above has been uncommented) +#!packages/build/ + +!src/packages/**/*.dll +!src/packages/**/*.pdb + +# Windows Azure Build Output +csx/ +*.build.csdef + +# Windows Store app package directory +AppPackages/ + +# Others +*.Cache +ClientBin/ +[Ss]tyle[Cc]op.* +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.pfx +*.publishsettings +node_modules/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# LightSwitch generated files +GeneratedArtifacts/ +_Pvt_Extensions/ +ModelManifest.xml +src/packages/EventStore.Client.3.3.1/lib/net40/EventStore.ClientAPI.xml + +# JetBrains Rider +.idea/ +*.sln.iml + +# Vagrant VM files +.vagrant +vagrant/dbv/data/meta/revision + +# Visual Studio 2015 cache/options directory +.vs/ \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea5ebf9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,205 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.sln.docstates + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +x64/ +bld/ +[Bb]in/ +[Oo]bj/ + +# Roslyn cache directories +*.ide/ + +# AWS +*.aws-sam/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +#NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opensdf +*.sdf +*.cachefile + +# Visual Studio profiler +*.psess +*.vsp +*.vspx + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding addin-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +## TODO: Comment the next line if you want to checkin your +## web deploy settings but do note that will include unencrypted +## passwords +#*.pubxml + +# NuGet Packages Directory +#packages/* +## TODO: If the tool you use requires repositories.config +## uncomment the next line +#!packages/repositories.config + +# Enable "build/" folder in the NuGet Packages folder since +# NuGet packages use it for MSBuild targets. +# This line needs to be after the ignore of the build folder +# (and the packages folder if the line above has been uncommented) +#!packages/build/ + +!src/packages/**/*.dll +!src/packages/**/*.pdb + +# Windows Azure Build Output +csx/ +*.build.csdef + +# Windows Store app package directory +AppPackages/ + +# Others +*.Cache +ClientBin/ +[Ss]tyle[Cc]op.* +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.pfx +*.publishsettings +node_modules/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# LightSwitch generated files +GeneratedArtifacts/ +_Pvt_Extensions/ +ModelManifest.xml +src/packages/EventStore.Client.3.3.1/lib/net40/EventStore.ClientAPI.xml + +# JetBrains Rider +.idea/ +*.sln.iml + +# Vagrant VM files +.vagrant +vagrant/dbv/data/meta/revision + +# Visual Studio 2015 cache/options directory +.vs/ \ No newline at end of file diff --git a/EventSourced/Events.cs b/EventSourced/Events.cs new file mode 100644 index 0000000..a62dcd4 --- /dev/null +++ b/EventSourced/Events.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; + +namespace AggregateConsistencyBoundary.EventSourced +{ + public interface IEvent { } + + public record Dispatched(int ShipmentId, IEnumerable Stops, DateTime Start) : IEvent; + + public record Arrived(int StopId, DateTime Arrival) : IEvent; + + public record PickedUp(int StopId, DateTime Loaded) : IEvent; + + public record Delivered(int StopId, DateTime Delivery) : IEvent; +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea5ebf9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,205 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.sln.docstates + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +x64/ +bld/ +[Bb]in/ +[Oo]bj/ + +# Roslyn cache directories +*.ide/ + +# AWS +*.aws-sam/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +#NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opensdf +*.sdf +*.cachefile + +# Visual Studio profiler +*.psess +*.vsp +*.vspx + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding addin-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +## TODO: Comment the next line if you want to checkin your +## web deploy settings but do note that will include unencrypted +## passwords +#*.pubxml + +# NuGet Packages Directory +#packages/* +## TODO: If the tool you use requires repositories.config +## uncomment the next line +#!packages/repositories.config + +# Enable "build/" folder in the NuGet Packages folder since +# NuGet packages use it for MSBuild targets. +# This line needs to be after the ignore of the build folder +# (and the packages folder if the line above has been uncommented) +#!packages/build/ + +!src/packages/**/*.dll +!src/packages/**/*.pdb + +# Windows Azure Build Output +csx/ +*.build.csdef + +# Windows Store app package directory +AppPackages/ + +# Others +*.Cache +ClientBin/ +[Ss]tyle[Cc]op.* +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.pfx +*.publishsettings +node_modules/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# LightSwitch generated files +GeneratedArtifacts/ +_Pvt_Extensions/ +ModelManifest.xml +src/packages/EventStore.Client.3.3.1/lib/net40/EventStore.ClientAPI.xml + +# JetBrains Rider +.idea/ +*.sln.iml + +# Vagrant VM files +.vagrant +vagrant/dbv/data/meta/revision + +# Visual Studio 2015 cache/options directory +.vs/ \ No newline at end of file diff --git a/EventSourced/Events.cs b/EventSourced/Events.cs new file mode 100644 index 0000000..a62dcd4 --- /dev/null +++ b/EventSourced/Events.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; + +namespace AggregateConsistencyBoundary.EventSourced +{ + public interface IEvent { } + + public record Dispatched(int ShipmentId, IEnumerable Stops, DateTime Start) : IEvent; + + public record Arrived(int StopId, DateTime Arrival) : IEvent; + + public record PickedUp(int StopId, DateTime Loaded) : IEvent; + + public record Delivered(int StopId, DateTime Delivery) : IEvent; +} \ No newline at end of file diff --git a/EventSourced/ShipmentAggregateRoot.cs b/EventSourced/ShipmentAggregateRoot.cs new file mode 100644 index 0000000..63acf6f --- /dev/null +++ b/EventSourced/ShipmentAggregateRoot.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace AggregateConsistencyBoundary.EventSourced +{ + public class ShipmentAggregateRoot + { + private readonly ShipmentState _projection = new(); + + public void Dispatch(int shipmentId, IEnumerable stops) + { + var dispatched = new Dispatched(shipmentId, stops, DateTime.UtcNow); + Apply(dispatched); + } + + private void Apply(Dispatched dispatched) + { + _projection.Stops = dispatched.Stops.OrderBy(x => x.Sequence).Select(x => new StopState(x.StopId, x.StopType, StopStatus.InTransit)).ToList(); + _projection.CurrentStopState = _projection.Stops.First(); + } + + public void Arrive(int stopId) + { + if (IsComplete()) + { + throw new InvalidOperationException("Shipment is already complete."); + } + + if (_projection.CurrentStopState.StopId != stopId) + { + throw new InvalidOperationException("Stop does not exist or is not the current stop."); + } + + if (_projection.CurrentStopState.Status != StopStatus.InTransit) + { + throw new InvalidOperationException("Stop has already arrived."); + } + + var arrived = new Arrived(stopId, DateTime.UtcNow); + Apply(arrived); + } + + private void Apply(Arrived arrived) + { + _projection.CurrentStopState.Status = StopStatus.Arrived; + } + + public void Pickup(int stopId) + { + if (IsComplete()) + { + throw new InvalidOperationException("Shipment is already complete."); + } + + if (_projection.CurrentStopState.StopId != stopId) + { + throw new InvalidOperationException("Stop does not exist or is not the current stop."); + } + + if (_projection.CurrentStopState.Type != StopType.Pickup) + { + throw new InvalidOperationException("Stop is not a pickup."); + } + + if (_projection.CurrentStopState.Status == StopStatus.Departed) + { + throw new InvalidOperationException("Stop has already departed."); + } + + if (_projection.CurrentStopState.Status == StopStatus.InTransit) + { + throw new InvalidOperationException("Stop hasn't arrived yet."); + } + + var pickedUp = new PickedUp(stopId, DateTime.UtcNow); + Apply(pickedUp); + } + + private void Apply(PickedUp pickedUp) + { + _projection.CurrentStopState = _projection.Stops.SkipWhile(x => x.Status != StopStatus.InTransit).FirstOrDefault(); + } + + public void Deliver(int stopId) + { + if (IsComplete()) + { + throw new InvalidOperationException("Shipment is already complete."); + } + + if (_projection.CurrentStopState.StopId != stopId) + { + throw new InvalidOperationException("Stop does not exist or is not the current stop."); + } + + if (_projection.CurrentStopState.Type != StopType.Delivery) + { + throw new InvalidOperationException("Stop is not a delivery."); + } + + if (_projection.CurrentStopState.Status == StopStatus.Departed) + { + throw new InvalidOperationException("Stop has already departed."); + } + + if (_projection.CurrentStopState.Status == StopStatus.InTransit) + { + throw new InvalidOperationException("Stop hasn't arrived yet."); + } + + var delivered = new Delivered(stopId, DateTime.UtcNow); + Apply(delivered); + } + + private void Apply(Delivered delivered) + { + _projection.CurrentStopState = _projection.Stops.SkipWhile(x => x.Status != StopStatus.InTransit).FirstOrDefault(); + } + + public bool IsComplete() + { + return _projection.CurrentStopState == null; + } + } + + public record ShipmentStop(int StopId, StopType StopType, int Sequence); +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea5ebf9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,205 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.sln.docstates + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +x64/ +bld/ +[Bb]in/ +[Oo]bj/ + +# Roslyn cache directories +*.ide/ + +# AWS +*.aws-sam/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +#NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opensdf +*.sdf +*.cachefile + +# Visual Studio profiler +*.psess +*.vsp +*.vspx + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding addin-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +## TODO: Comment the next line if you want to checkin your +## web deploy settings but do note that will include unencrypted +## passwords +#*.pubxml + +# NuGet Packages Directory +#packages/* +## TODO: If the tool you use requires repositories.config +## uncomment the next line +#!packages/repositories.config + +# Enable "build/" folder in the NuGet Packages folder since +# NuGet packages use it for MSBuild targets. +# This line needs to be after the ignore of the build folder +# (and the packages folder if the line above has been uncommented) +#!packages/build/ + +!src/packages/**/*.dll +!src/packages/**/*.pdb + +# Windows Azure Build Output +csx/ +*.build.csdef + +# Windows Store app package directory +AppPackages/ + +# Others +*.Cache +ClientBin/ +[Ss]tyle[Cc]op.* +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.pfx +*.publishsettings +node_modules/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# LightSwitch generated files +GeneratedArtifacts/ +_Pvt_Extensions/ +ModelManifest.xml +src/packages/EventStore.Client.3.3.1/lib/net40/EventStore.ClientAPI.xml + +# JetBrains Rider +.idea/ +*.sln.iml + +# Vagrant VM files +.vagrant +vagrant/dbv/data/meta/revision + +# Visual Studio 2015 cache/options directory +.vs/ \ No newline at end of file diff --git a/EventSourced/Events.cs b/EventSourced/Events.cs new file mode 100644 index 0000000..a62dcd4 --- /dev/null +++ b/EventSourced/Events.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; + +namespace AggregateConsistencyBoundary.EventSourced +{ + public interface IEvent { } + + public record Dispatched(int ShipmentId, IEnumerable Stops, DateTime Start) : IEvent; + + public record Arrived(int StopId, DateTime Arrival) : IEvent; + + public record PickedUp(int StopId, DateTime Loaded) : IEvent; + + public record Delivered(int StopId, DateTime Delivery) : IEvent; +} \ No newline at end of file diff --git a/EventSourced/ShipmentAggregateRoot.cs b/EventSourced/ShipmentAggregateRoot.cs new file mode 100644 index 0000000..63acf6f --- /dev/null +++ b/EventSourced/ShipmentAggregateRoot.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace AggregateConsistencyBoundary.EventSourced +{ + public class ShipmentAggregateRoot + { + private readonly ShipmentState _projection = new(); + + public void Dispatch(int shipmentId, IEnumerable stops) + { + var dispatched = new Dispatched(shipmentId, stops, DateTime.UtcNow); + Apply(dispatched); + } + + private void Apply(Dispatched dispatched) + { + _projection.Stops = dispatched.Stops.OrderBy(x => x.Sequence).Select(x => new StopState(x.StopId, x.StopType, StopStatus.InTransit)).ToList(); + _projection.CurrentStopState = _projection.Stops.First(); + } + + public void Arrive(int stopId) + { + if (IsComplete()) + { + throw new InvalidOperationException("Shipment is already complete."); + } + + if (_projection.CurrentStopState.StopId != stopId) + { + throw new InvalidOperationException("Stop does not exist or is not the current stop."); + } + + if (_projection.CurrentStopState.Status != StopStatus.InTransit) + { + throw new InvalidOperationException("Stop has already arrived."); + } + + var arrived = new Arrived(stopId, DateTime.UtcNow); + Apply(arrived); + } + + private void Apply(Arrived arrived) + { + _projection.CurrentStopState.Status = StopStatus.Arrived; + } + + public void Pickup(int stopId) + { + if (IsComplete()) + { + throw new InvalidOperationException("Shipment is already complete."); + } + + if (_projection.CurrentStopState.StopId != stopId) + { + throw new InvalidOperationException("Stop does not exist or is not the current stop."); + } + + if (_projection.CurrentStopState.Type != StopType.Pickup) + { + throw new InvalidOperationException("Stop is not a pickup."); + } + + if (_projection.CurrentStopState.Status == StopStatus.Departed) + { + throw new InvalidOperationException("Stop has already departed."); + } + + if (_projection.CurrentStopState.Status == StopStatus.InTransit) + { + throw new InvalidOperationException("Stop hasn't arrived yet."); + } + + var pickedUp = new PickedUp(stopId, DateTime.UtcNow); + Apply(pickedUp); + } + + private void Apply(PickedUp pickedUp) + { + _projection.CurrentStopState = _projection.Stops.SkipWhile(x => x.Status != StopStatus.InTransit).FirstOrDefault(); + } + + public void Deliver(int stopId) + { + if (IsComplete()) + { + throw new InvalidOperationException("Shipment is already complete."); + } + + if (_projection.CurrentStopState.StopId != stopId) + { + throw new InvalidOperationException("Stop does not exist or is not the current stop."); + } + + if (_projection.CurrentStopState.Type != StopType.Delivery) + { + throw new InvalidOperationException("Stop is not a delivery."); + } + + if (_projection.CurrentStopState.Status == StopStatus.Departed) + { + throw new InvalidOperationException("Stop has already departed."); + } + + if (_projection.CurrentStopState.Status == StopStatus.InTransit) + { + throw new InvalidOperationException("Stop hasn't arrived yet."); + } + + var delivered = new Delivered(stopId, DateTime.UtcNow); + Apply(delivered); + } + + private void Apply(Delivered delivered) + { + _projection.CurrentStopState = _projection.Stops.SkipWhile(x => x.Status != StopStatus.InTransit).FirstOrDefault(); + } + + public bool IsComplete() + { + return _projection.CurrentStopState == null; + } + } + + public record ShipmentStop(int StopId, StopType StopType, int Sequence); +} diff --git a/EventSourced/ShipmentState.cs b/EventSourced/ShipmentState.cs new file mode 100644 index 0000000..525b6ba --- /dev/null +++ b/EventSourced/ShipmentState.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace AggregateConsistencyBoundary.EventSourced +{ + public class ShipmentState + { + public List Stops { get; set; } + public StopState CurrentStopState { get; set; } + } + + public class StopState + { + public StopState(int stopId, StopType type, StopStatus status) + { + StopId = stopId; + Type = type; + Status = status; + } + + public int StopId { get; set; } + public StopType Type { get; set; } + public StopStatus Status { get; set; } + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea5ebf9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,205 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.sln.docstates + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +x64/ +bld/ +[Bb]in/ +[Oo]bj/ + +# Roslyn cache directories +*.ide/ + +# AWS +*.aws-sam/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +#NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opensdf +*.sdf +*.cachefile + +# Visual Studio profiler +*.psess +*.vsp +*.vspx + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding addin-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +## TODO: Comment the next line if you want to checkin your +## web deploy settings but do note that will include unencrypted +## passwords +#*.pubxml + +# NuGet Packages Directory +#packages/* +## TODO: If the tool you use requires repositories.config +## uncomment the next line +#!packages/repositories.config + +# Enable "build/" folder in the NuGet Packages folder since +# NuGet packages use it for MSBuild targets. +# This line needs to be after the ignore of the build folder +# (and the packages folder if the line above has been uncommented) +#!packages/build/ + +!src/packages/**/*.dll +!src/packages/**/*.pdb + +# Windows Azure Build Output +csx/ +*.build.csdef + +# Windows Store app package directory +AppPackages/ + +# Others +*.Cache +ClientBin/ +[Ss]tyle[Cc]op.* +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.pfx +*.publishsettings +node_modules/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# LightSwitch generated files +GeneratedArtifacts/ +_Pvt_Extensions/ +ModelManifest.xml +src/packages/EventStore.Client.3.3.1/lib/net40/EventStore.ClientAPI.xml + +# JetBrains Rider +.idea/ +*.sln.iml + +# Vagrant VM files +.vagrant +vagrant/dbv/data/meta/revision + +# Visual Studio 2015 cache/options directory +.vs/ \ No newline at end of file diff --git a/EventSourced/Events.cs b/EventSourced/Events.cs new file mode 100644 index 0000000..a62dcd4 --- /dev/null +++ b/EventSourced/Events.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; + +namespace AggregateConsistencyBoundary.EventSourced +{ + public interface IEvent { } + + public record Dispatched(int ShipmentId, IEnumerable Stops, DateTime Start) : IEvent; + + public record Arrived(int StopId, DateTime Arrival) : IEvent; + + public record PickedUp(int StopId, DateTime Loaded) : IEvent; + + public record Delivered(int StopId, DateTime Delivery) : IEvent; +} \ No newline at end of file diff --git a/EventSourced/ShipmentAggregateRoot.cs b/EventSourced/ShipmentAggregateRoot.cs new file mode 100644 index 0000000..63acf6f --- /dev/null +++ b/EventSourced/ShipmentAggregateRoot.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace AggregateConsistencyBoundary.EventSourced +{ + public class ShipmentAggregateRoot + { + private readonly ShipmentState _projection = new(); + + public void Dispatch(int shipmentId, IEnumerable stops) + { + var dispatched = new Dispatched(shipmentId, stops, DateTime.UtcNow); + Apply(dispatched); + } + + private void Apply(Dispatched dispatched) + { + _projection.Stops = dispatched.Stops.OrderBy(x => x.Sequence).Select(x => new StopState(x.StopId, x.StopType, StopStatus.InTransit)).ToList(); + _projection.CurrentStopState = _projection.Stops.First(); + } + + public void Arrive(int stopId) + { + if (IsComplete()) + { + throw new InvalidOperationException("Shipment is already complete."); + } + + if (_projection.CurrentStopState.StopId != stopId) + { + throw new InvalidOperationException("Stop does not exist or is not the current stop."); + } + + if (_projection.CurrentStopState.Status != StopStatus.InTransit) + { + throw new InvalidOperationException("Stop has already arrived."); + } + + var arrived = new Arrived(stopId, DateTime.UtcNow); + Apply(arrived); + } + + private void Apply(Arrived arrived) + { + _projection.CurrentStopState.Status = StopStatus.Arrived; + } + + public void Pickup(int stopId) + { + if (IsComplete()) + { + throw new InvalidOperationException("Shipment is already complete."); + } + + if (_projection.CurrentStopState.StopId != stopId) + { + throw new InvalidOperationException("Stop does not exist or is not the current stop."); + } + + if (_projection.CurrentStopState.Type != StopType.Pickup) + { + throw new InvalidOperationException("Stop is not a pickup."); + } + + if (_projection.CurrentStopState.Status == StopStatus.Departed) + { + throw new InvalidOperationException("Stop has already departed."); + } + + if (_projection.CurrentStopState.Status == StopStatus.InTransit) + { + throw new InvalidOperationException("Stop hasn't arrived yet."); + } + + var pickedUp = new PickedUp(stopId, DateTime.UtcNow); + Apply(pickedUp); + } + + private void Apply(PickedUp pickedUp) + { + _projection.CurrentStopState = _projection.Stops.SkipWhile(x => x.Status != StopStatus.InTransit).FirstOrDefault(); + } + + public void Deliver(int stopId) + { + if (IsComplete()) + { + throw new InvalidOperationException("Shipment is already complete."); + } + + if (_projection.CurrentStopState.StopId != stopId) + { + throw new InvalidOperationException("Stop does not exist or is not the current stop."); + } + + if (_projection.CurrentStopState.Type != StopType.Delivery) + { + throw new InvalidOperationException("Stop is not a delivery."); + } + + if (_projection.CurrentStopState.Status == StopStatus.Departed) + { + throw new InvalidOperationException("Stop has already departed."); + } + + if (_projection.CurrentStopState.Status == StopStatus.InTransit) + { + throw new InvalidOperationException("Stop hasn't arrived yet."); + } + + var delivered = new Delivered(stopId, DateTime.UtcNow); + Apply(delivered); + } + + private void Apply(Delivered delivered) + { + _projection.CurrentStopState = _projection.Stops.SkipWhile(x => x.Status != StopStatus.InTransit).FirstOrDefault(); + } + + public bool IsComplete() + { + return _projection.CurrentStopState == null; + } + } + + public record ShipmentStop(int StopId, StopType StopType, int Sequence); +} diff --git a/EventSourced/ShipmentState.cs b/EventSourced/ShipmentState.cs new file mode 100644 index 0000000..525b6ba --- /dev/null +++ b/EventSourced/ShipmentState.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace AggregateConsistencyBoundary.EventSourced +{ + public class ShipmentState + { + public List Stops { get; set; } + public StopState CurrentStopState { get; set; } + } + + public class StopState + { + public StopState(int stopId, StopType type, StopStatus status) + { + StopId = stopId; + Type = type; + Status = status; + } + + public int StopId { get; set; } + public StopType Type { get; set; } + public StopStatus Status { get; set; } + } +} \ No newline at end of file diff --git a/EventSourced/Tests.cs b/EventSourced/Tests.cs new file mode 100644 index 0000000..cc50083 --- /dev/null +++ b/EventSourced/Tests.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using Shouldly; +using Xunit; + +namespace AggregateConsistencyBoundary.EventSourced +{ + public class Tests + { + private readonly ShipmentAggregateRoot _shipmentAggregateRootAggregateRoot; + + public Tests() + { + var stops = new List + { + new ShipmentStop(1, StopType.Pickup, 1), + new ShipmentStop(2, StopType.Delivery, 2) + }; + _shipmentAggregateRootAggregateRoot = new ShipmentAggregateRoot(); + _shipmentAggregateRootAggregateRoot.Dispatch(1, stops); + } + + [Fact] + public void CompleteShipment() + { + _shipmentAggregateRootAggregateRoot.Arrive(1); + _shipmentAggregateRootAggregateRoot.Pickup(1); + _shipmentAggregateRootAggregateRoot.Arrive(2); + _shipmentAggregateRootAggregateRoot.Deliver(2); + _shipmentAggregateRootAggregateRoot.IsComplete().ShouldBeTrue(); + } + + [Fact] + public void CannotPickupWithoutArriving() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Pickup(1), "Stop hasn't arrived yet."); + } + + [Fact] + public void CannotDeliverWithoutArriving() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Deliver(2), "Stop hasn't arrived yet."); + } + + [Fact] + public void CannotPickupAtDelivery() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Pickup(2), "Stop is not a delivery."); + } + + [Fact] + public void CannotDeliverAtPickup() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Deliver(1), "Stop is not a pickup."); + } + + [Fact] + public void ArriveStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Arrive(0), "Stop does not exist."); + } + + [Fact] + public void PickupStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Pickup(0), "Stop does not exist."); + } + + [Fact] + public void DeliverStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Deliver(0), "Stop does not exist."); + } + + [Fact] + public void ArriveNonDepartedStops() + { + _shipmentAggregateRootAggregateRoot.Arrive(1); + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Arrive(2), "Previous stops have not departed."); + } + + [Fact] + public void AlreadyArrived() + { + _shipmentAggregateRootAggregateRoot.Arrive(1); + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Arrive(1), "Stop has already arrived."); + } + + [Fact] + public void AlreadyPickedUp() + { + _shipmentAggregateRootAggregateRoot.Arrive(1); + _shipmentAggregateRootAggregateRoot.Pickup(1); + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Pickup(1), "Stop has already departed."); + } + + [Fact] + public void AlreadyDelivered() + { + _shipmentAggregateRootAggregateRoot.Arrive(1); + _shipmentAggregateRootAggregateRoot.Pickup(1); + _shipmentAggregateRootAggregateRoot.Arrive(2); + _shipmentAggregateRootAggregateRoot.Deliver(2); + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Deliver(2), "Stop has already departed."); + } + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea5ebf9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,205 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.sln.docstates + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +x64/ +bld/ +[Bb]in/ +[Oo]bj/ + +# Roslyn cache directories +*.ide/ + +# AWS +*.aws-sam/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +#NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opensdf +*.sdf +*.cachefile + +# Visual Studio profiler +*.psess +*.vsp +*.vspx + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding addin-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +## TODO: Comment the next line if you want to checkin your +## web deploy settings but do note that will include unencrypted +## passwords +#*.pubxml + +# NuGet Packages Directory +#packages/* +## TODO: If the tool you use requires repositories.config +## uncomment the next line +#!packages/repositories.config + +# Enable "build/" folder in the NuGet Packages folder since +# NuGet packages use it for MSBuild targets. +# This line needs to be after the ignore of the build folder +# (and the packages folder if the line above has been uncommented) +#!packages/build/ + +!src/packages/**/*.dll +!src/packages/**/*.pdb + +# Windows Azure Build Output +csx/ +*.build.csdef + +# Windows Store app package directory +AppPackages/ + +# Others +*.Cache +ClientBin/ +[Ss]tyle[Cc]op.* +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.pfx +*.publishsettings +node_modules/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# LightSwitch generated files +GeneratedArtifacts/ +_Pvt_Extensions/ +ModelManifest.xml +src/packages/EventStore.Client.3.3.1/lib/net40/EventStore.ClientAPI.xml + +# JetBrains Rider +.idea/ +*.sln.iml + +# Vagrant VM files +.vagrant +vagrant/dbv/data/meta/revision + +# Visual Studio 2015 cache/options directory +.vs/ \ No newline at end of file diff --git a/EventSourced/Events.cs b/EventSourced/Events.cs new file mode 100644 index 0000000..a62dcd4 --- /dev/null +++ b/EventSourced/Events.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; + +namespace AggregateConsistencyBoundary.EventSourced +{ + public interface IEvent { } + + public record Dispatched(int ShipmentId, IEnumerable Stops, DateTime Start) : IEvent; + + public record Arrived(int StopId, DateTime Arrival) : IEvent; + + public record PickedUp(int StopId, DateTime Loaded) : IEvent; + + public record Delivered(int StopId, DateTime Delivery) : IEvent; +} \ No newline at end of file diff --git a/EventSourced/ShipmentAggregateRoot.cs b/EventSourced/ShipmentAggregateRoot.cs new file mode 100644 index 0000000..63acf6f --- /dev/null +++ b/EventSourced/ShipmentAggregateRoot.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace AggregateConsistencyBoundary.EventSourced +{ + public class ShipmentAggregateRoot + { + private readonly ShipmentState _projection = new(); + + public void Dispatch(int shipmentId, IEnumerable stops) + { + var dispatched = new Dispatched(shipmentId, stops, DateTime.UtcNow); + Apply(dispatched); + } + + private void Apply(Dispatched dispatched) + { + _projection.Stops = dispatched.Stops.OrderBy(x => x.Sequence).Select(x => new StopState(x.StopId, x.StopType, StopStatus.InTransit)).ToList(); + _projection.CurrentStopState = _projection.Stops.First(); + } + + public void Arrive(int stopId) + { + if (IsComplete()) + { + throw new InvalidOperationException("Shipment is already complete."); + } + + if (_projection.CurrentStopState.StopId != stopId) + { + throw new InvalidOperationException("Stop does not exist or is not the current stop."); + } + + if (_projection.CurrentStopState.Status != StopStatus.InTransit) + { + throw new InvalidOperationException("Stop has already arrived."); + } + + var arrived = new Arrived(stopId, DateTime.UtcNow); + Apply(arrived); + } + + private void Apply(Arrived arrived) + { + _projection.CurrentStopState.Status = StopStatus.Arrived; + } + + public void Pickup(int stopId) + { + if (IsComplete()) + { + throw new InvalidOperationException("Shipment is already complete."); + } + + if (_projection.CurrentStopState.StopId != stopId) + { + throw new InvalidOperationException("Stop does not exist or is not the current stop."); + } + + if (_projection.CurrentStopState.Type != StopType.Pickup) + { + throw new InvalidOperationException("Stop is not a pickup."); + } + + if (_projection.CurrentStopState.Status == StopStatus.Departed) + { + throw new InvalidOperationException("Stop has already departed."); + } + + if (_projection.CurrentStopState.Status == StopStatus.InTransit) + { + throw new InvalidOperationException("Stop hasn't arrived yet."); + } + + var pickedUp = new PickedUp(stopId, DateTime.UtcNow); + Apply(pickedUp); + } + + private void Apply(PickedUp pickedUp) + { + _projection.CurrentStopState = _projection.Stops.SkipWhile(x => x.Status != StopStatus.InTransit).FirstOrDefault(); + } + + public void Deliver(int stopId) + { + if (IsComplete()) + { + throw new InvalidOperationException("Shipment is already complete."); + } + + if (_projection.CurrentStopState.StopId != stopId) + { + throw new InvalidOperationException("Stop does not exist or is not the current stop."); + } + + if (_projection.CurrentStopState.Type != StopType.Delivery) + { + throw new InvalidOperationException("Stop is not a delivery."); + } + + if (_projection.CurrentStopState.Status == StopStatus.Departed) + { + throw new InvalidOperationException("Stop has already departed."); + } + + if (_projection.CurrentStopState.Status == StopStatus.InTransit) + { + throw new InvalidOperationException("Stop hasn't arrived yet."); + } + + var delivered = new Delivered(stopId, DateTime.UtcNow); + Apply(delivered); + } + + private void Apply(Delivered delivered) + { + _projection.CurrentStopState = _projection.Stops.SkipWhile(x => x.Status != StopStatus.InTransit).FirstOrDefault(); + } + + public bool IsComplete() + { + return _projection.CurrentStopState == null; + } + } + + public record ShipmentStop(int StopId, StopType StopType, int Sequence); +} diff --git a/EventSourced/ShipmentState.cs b/EventSourced/ShipmentState.cs new file mode 100644 index 0000000..525b6ba --- /dev/null +++ b/EventSourced/ShipmentState.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace AggregateConsistencyBoundary.EventSourced +{ + public class ShipmentState + { + public List Stops { get; set; } + public StopState CurrentStopState { get; set; } + } + + public class StopState + { + public StopState(int stopId, StopType type, StopStatus status) + { + StopId = stopId; + Type = type; + Status = status; + } + + public int StopId { get; set; } + public StopType Type { get; set; } + public StopStatus Status { get; set; } + } +} \ No newline at end of file diff --git a/EventSourced/Tests.cs b/EventSourced/Tests.cs new file mode 100644 index 0000000..cc50083 --- /dev/null +++ b/EventSourced/Tests.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using Shouldly; +using Xunit; + +namespace AggregateConsistencyBoundary.EventSourced +{ + public class Tests + { + private readonly ShipmentAggregateRoot _shipmentAggregateRootAggregateRoot; + + public Tests() + { + var stops = new List + { + new ShipmentStop(1, StopType.Pickup, 1), + new ShipmentStop(2, StopType.Delivery, 2) + }; + _shipmentAggregateRootAggregateRoot = new ShipmentAggregateRoot(); + _shipmentAggregateRootAggregateRoot.Dispatch(1, stops); + } + + [Fact] + public void CompleteShipment() + { + _shipmentAggregateRootAggregateRoot.Arrive(1); + _shipmentAggregateRootAggregateRoot.Pickup(1); + _shipmentAggregateRootAggregateRoot.Arrive(2); + _shipmentAggregateRootAggregateRoot.Deliver(2); + _shipmentAggregateRootAggregateRoot.IsComplete().ShouldBeTrue(); + } + + [Fact] + public void CannotPickupWithoutArriving() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Pickup(1), "Stop hasn't arrived yet."); + } + + [Fact] + public void CannotDeliverWithoutArriving() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Deliver(2), "Stop hasn't arrived yet."); + } + + [Fact] + public void CannotPickupAtDelivery() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Pickup(2), "Stop is not a delivery."); + } + + [Fact] + public void CannotDeliverAtPickup() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Deliver(1), "Stop is not a pickup."); + } + + [Fact] + public void ArriveStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Arrive(0), "Stop does not exist."); + } + + [Fact] + public void PickupStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Pickup(0), "Stop does not exist."); + } + + [Fact] + public void DeliverStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Deliver(0), "Stop does not exist."); + } + + [Fact] + public void ArriveNonDepartedStops() + { + _shipmentAggregateRootAggregateRoot.Arrive(1); + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Arrive(2), "Previous stops have not departed."); + } + + [Fact] + public void AlreadyArrived() + { + _shipmentAggregateRootAggregateRoot.Arrive(1); + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Arrive(1), "Stop has already arrived."); + } + + [Fact] + public void AlreadyPickedUp() + { + _shipmentAggregateRootAggregateRoot.Arrive(1); + _shipmentAggregateRootAggregateRoot.Pickup(1); + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Pickup(1), "Stop has already departed."); + } + + [Fact] + public void AlreadyDelivered() + { + _shipmentAggregateRootAggregateRoot.Arrive(1); + _shipmentAggregateRootAggregateRoot.Pickup(1); + _shipmentAggregateRootAggregateRoot.Arrive(2); + _shipmentAggregateRootAggregateRoot.Deliver(2); + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Deliver(2), "Stop has already departed."); + } + } +} \ No newline at end of file diff --git a/EventSourcing-ShipmentAggregate.csproj b/EventSourcing-ShipmentAggregate.csproj new file mode 100644 index 0000000..a71d4c2 --- /dev/null +++ b/EventSourcing-ShipmentAggregate.csproj @@ -0,0 +1,12 @@ + + + + net5.0 + + + + + + + + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea5ebf9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,205 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.sln.docstates + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +x64/ +bld/ +[Bb]in/ +[Oo]bj/ + +# Roslyn cache directories +*.ide/ + +# AWS +*.aws-sam/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +#NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opensdf +*.sdf +*.cachefile + +# Visual Studio profiler +*.psess +*.vsp +*.vspx + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding addin-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +## TODO: Comment the next line if you want to checkin your +## web deploy settings but do note that will include unencrypted +## passwords +#*.pubxml + +# NuGet Packages Directory +#packages/* +## TODO: If the tool you use requires repositories.config +## uncomment the next line +#!packages/repositories.config + +# Enable "build/" folder in the NuGet Packages folder since +# NuGet packages use it for MSBuild targets. +# This line needs to be after the ignore of the build folder +# (and the packages folder if the line above has been uncommented) +#!packages/build/ + +!src/packages/**/*.dll +!src/packages/**/*.pdb + +# Windows Azure Build Output +csx/ +*.build.csdef + +# Windows Store app package directory +AppPackages/ + +# Others +*.Cache +ClientBin/ +[Ss]tyle[Cc]op.* +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.pfx +*.publishsettings +node_modules/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# LightSwitch generated files +GeneratedArtifacts/ +_Pvt_Extensions/ +ModelManifest.xml +src/packages/EventStore.Client.3.3.1/lib/net40/EventStore.ClientAPI.xml + +# JetBrains Rider +.idea/ +*.sln.iml + +# Vagrant VM files +.vagrant +vagrant/dbv/data/meta/revision + +# Visual Studio 2015 cache/options directory +.vs/ \ No newline at end of file diff --git a/EventSourced/Events.cs b/EventSourced/Events.cs new file mode 100644 index 0000000..a62dcd4 --- /dev/null +++ b/EventSourced/Events.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; + +namespace AggregateConsistencyBoundary.EventSourced +{ + public interface IEvent { } + + public record Dispatched(int ShipmentId, IEnumerable Stops, DateTime Start) : IEvent; + + public record Arrived(int StopId, DateTime Arrival) : IEvent; + + public record PickedUp(int StopId, DateTime Loaded) : IEvent; + + public record Delivered(int StopId, DateTime Delivery) : IEvent; +} \ No newline at end of file diff --git a/EventSourced/ShipmentAggregateRoot.cs b/EventSourced/ShipmentAggregateRoot.cs new file mode 100644 index 0000000..63acf6f --- /dev/null +++ b/EventSourced/ShipmentAggregateRoot.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace AggregateConsistencyBoundary.EventSourced +{ + public class ShipmentAggregateRoot + { + private readonly ShipmentState _projection = new(); + + public void Dispatch(int shipmentId, IEnumerable stops) + { + var dispatched = new Dispatched(shipmentId, stops, DateTime.UtcNow); + Apply(dispatched); + } + + private void Apply(Dispatched dispatched) + { + _projection.Stops = dispatched.Stops.OrderBy(x => x.Sequence).Select(x => new StopState(x.StopId, x.StopType, StopStatus.InTransit)).ToList(); + _projection.CurrentStopState = _projection.Stops.First(); + } + + public void Arrive(int stopId) + { + if (IsComplete()) + { + throw new InvalidOperationException("Shipment is already complete."); + } + + if (_projection.CurrentStopState.StopId != stopId) + { + throw new InvalidOperationException("Stop does not exist or is not the current stop."); + } + + if (_projection.CurrentStopState.Status != StopStatus.InTransit) + { + throw new InvalidOperationException("Stop has already arrived."); + } + + var arrived = new Arrived(stopId, DateTime.UtcNow); + Apply(arrived); + } + + private void Apply(Arrived arrived) + { + _projection.CurrentStopState.Status = StopStatus.Arrived; + } + + public void Pickup(int stopId) + { + if (IsComplete()) + { + throw new InvalidOperationException("Shipment is already complete."); + } + + if (_projection.CurrentStopState.StopId != stopId) + { + throw new InvalidOperationException("Stop does not exist or is not the current stop."); + } + + if (_projection.CurrentStopState.Type != StopType.Pickup) + { + throw new InvalidOperationException("Stop is not a pickup."); + } + + if (_projection.CurrentStopState.Status == StopStatus.Departed) + { + throw new InvalidOperationException("Stop has already departed."); + } + + if (_projection.CurrentStopState.Status == StopStatus.InTransit) + { + throw new InvalidOperationException("Stop hasn't arrived yet."); + } + + var pickedUp = new PickedUp(stopId, DateTime.UtcNow); + Apply(pickedUp); + } + + private void Apply(PickedUp pickedUp) + { + _projection.CurrentStopState = _projection.Stops.SkipWhile(x => x.Status != StopStatus.InTransit).FirstOrDefault(); + } + + public void Deliver(int stopId) + { + if (IsComplete()) + { + throw new InvalidOperationException("Shipment is already complete."); + } + + if (_projection.CurrentStopState.StopId != stopId) + { + throw new InvalidOperationException("Stop does not exist or is not the current stop."); + } + + if (_projection.CurrentStopState.Type != StopType.Delivery) + { + throw new InvalidOperationException("Stop is not a delivery."); + } + + if (_projection.CurrentStopState.Status == StopStatus.Departed) + { + throw new InvalidOperationException("Stop has already departed."); + } + + if (_projection.CurrentStopState.Status == StopStatus.InTransit) + { + throw new InvalidOperationException("Stop hasn't arrived yet."); + } + + var delivered = new Delivered(stopId, DateTime.UtcNow); + Apply(delivered); + } + + private void Apply(Delivered delivered) + { + _projection.CurrentStopState = _projection.Stops.SkipWhile(x => x.Status != StopStatus.InTransit).FirstOrDefault(); + } + + public bool IsComplete() + { + return _projection.CurrentStopState == null; + } + } + + public record ShipmentStop(int StopId, StopType StopType, int Sequence); +} diff --git a/EventSourced/ShipmentState.cs b/EventSourced/ShipmentState.cs new file mode 100644 index 0000000..525b6ba --- /dev/null +++ b/EventSourced/ShipmentState.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace AggregateConsistencyBoundary.EventSourced +{ + public class ShipmentState + { + public List Stops { get; set; } + public StopState CurrentStopState { get; set; } + } + + public class StopState + { + public StopState(int stopId, StopType type, StopStatus status) + { + StopId = stopId; + Type = type; + Status = status; + } + + public int StopId { get; set; } + public StopType Type { get; set; } + public StopStatus Status { get; set; } + } +} \ No newline at end of file diff --git a/EventSourced/Tests.cs b/EventSourced/Tests.cs new file mode 100644 index 0000000..cc50083 --- /dev/null +++ b/EventSourced/Tests.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using Shouldly; +using Xunit; + +namespace AggregateConsistencyBoundary.EventSourced +{ + public class Tests + { + private readonly ShipmentAggregateRoot _shipmentAggregateRootAggregateRoot; + + public Tests() + { + var stops = new List + { + new ShipmentStop(1, StopType.Pickup, 1), + new ShipmentStop(2, StopType.Delivery, 2) + }; + _shipmentAggregateRootAggregateRoot = new ShipmentAggregateRoot(); + _shipmentAggregateRootAggregateRoot.Dispatch(1, stops); + } + + [Fact] + public void CompleteShipment() + { + _shipmentAggregateRootAggregateRoot.Arrive(1); + _shipmentAggregateRootAggregateRoot.Pickup(1); + _shipmentAggregateRootAggregateRoot.Arrive(2); + _shipmentAggregateRootAggregateRoot.Deliver(2); + _shipmentAggregateRootAggregateRoot.IsComplete().ShouldBeTrue(); + } + + [Fact] + public void CannotPickupWithoutArriving() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Pickup(1), "Stop hasn't arrived yet."); + } + + [Fact] + public void CannotDeliverWithoutArriving() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Deliver(2), "Stop hasn't arrived yet."); + } + + [Fact] + public void CannotPickupAtDelivery() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Pickup(2), "Stop is not a delivery."); + } + + [Fact] + public void CannotDeliverAtPickup() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Deliver(1), "Stop is not a pickup."); + } + + [Fact] + public void ArriveStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Arrive(0), "Stop does not exist."); + } + + [Fact] + public void PickupStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Pickup(0), "Stop does not exist."); + } + + [Fact] + public void DeliverStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Deliver(0), "Stop does not exist."); + } + + [Fact] + public void ArriveNonDepartedStops() + { + _shipmentAggregateRootAggregateRoot.Arrive(1); + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Arrive(2), "Previous stops have not departed."); + } + + [Fact] + public void AlreadyArrived() + { + _shipmentAggregateRootAggregateRoot.Arrive(1); + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Arrive(1), "Stop has already arrived."); + } + + [Fact] + public void AlreadyPickedUp() + { + _shipmentAggregateRootAggregateRoot.Arrive(1); + _shipmentAggregateRootAggregateRoot.Pickup(1); + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Pickup(1), "Stop has already departed."); + } + + [Fact] + public void AlreadyDelivered() + { + _shipmentAggregateRootAggregateRoot.Arrive(1); + _shipmentAggregateRootAggregateRoot.Pickup(1); + _shipmentAggregateRootAggregateRoot.Arrive(2); + _shipmentAggregateRootAggregateRoot.Deliver(2); + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Deliver(2), "Stop has already departed."); + } + } +} \ No newline at end of file diff --git a/EventSourcing-ShipmentAggregate.csproj b/EventSourcing-ShipmentAggregate.csproj new file mode 100644 index 0000000..a71d4c2 --- /dev/null +++ b/EventSourcing-ShipmentAggregate.csproj @@ -0,0 +1,12 @@ + + + + net5.0 + + + + + + + + diff --git a/README.md b/README.md index 4ccd1a7..acacb6b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ -EventSourcing-ShipmentAggregate +Event Sourcing: Shipment Aggregate Comparison =============== +https://youtu.be/C5TmLMZ4fXo diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea5ebf9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,205 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.sln.docstates + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +x64/ +bld/ +[Bb]in/ +[Oo]bj/ + +# Roslyn cache directories +*.ide/ + +# AWS +*.aws-sam/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +#NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opensdf +*.sdf +*.cachefile + +# Visual Studio profiler +*.psess +*.vsp +*.vspx + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding addin-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +## TODO: Comment the next line if you want to checkin your +## web deploy settings but do note that will include unencrypted +## passwords +#*.pubxml + +# NuGet Packages Directory +#packages/* +## TODO: If the tool you use requires repositories.config +## uncomment the next line +#!packages/repositories.config + +# Enable "build/" folder in the NuGet Packages folder since +# NuGet packages use it for MSBuild targets. +# This line needs to be after the ignore of the build folder +# (and the packages folder if the line above has been uncommented) +#!packages/build/ + +!src/packages/**/*.dll +!src/packages/**/*.pdb + +# Windows Azure Build Output +csx/ +*.build.csdef + +# Windows Store app package directory +AppPackages/ + +# Others +*.Cache +ClientBin/ +[Ss]tyle[Cc]op.* +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.pfx +*.publishsettings +node_modules/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# LightSwitch generated files +GeneratedArtifacts/ +_Pvt_Extensions/ +ModelManifest.xml +src/packages/EventStore.Client.3.3.1/lib/net40/EventStore.ClientAPI.xml + +# JetBrains Rider +.idea/ +*.sln.iml + +# Vagrant VM files +.vagrant +vagrant/dbv/data/meta/revision + +# Visual Studio 2015 cache/options directory +.vs/ \ No newline at end of file diff --git a/EventSourced/Events.cs b/EventSourced/Events.cs new file mode 100644 index 0000000..a62dcd4 --- /dev/null +++ b/EventSourced/Events.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; + +namespace AggregateConsistencyBoundary.EventSourced +{ + public interface IEvent { } + + public record Dispatched(int ShipmentId, IEnumerable Stops, DateTime Start) : IEvent; + + public record Arrived(int StopId, DateTime Arrival) : IEvent; + + public record PickedUp(int StopId, DateTime Loaded) : IEvent; + + public record Delivered(int StopId, DateTime Delivery) : IEvent; +} \ No newline at end of file diff --git a/EventSourced/ShipmentAggregateRoot.cs b/EventSourced/ShipmentAggregateRoot.cs new file mode 100644 index 0000000..63acf6f --- /dev/null +++ b/EventSourced/ShipmentAggregateRoot.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace AggregateConsistencyBoundary.EventSourced +{ + public class ShipmentAggregateRoot + { + private readonly ShipmentState _projection = new(); + + public void Dispatch(int shipmentId, IEnumerable stops) + { + var dispatched = new Dispatched(shipmentId, stops, DateTime.UtcNow); + Apply(dispatched); + } + + private void Apply(Dispatched dispatched) + { + _projection.Stops = dispatched.Stops.OrderBy(x => x.Sequence).Select(x => new StopState(x.StopId, x.StopType, StopStatus.InTransit)).ToList(); + _projection.CurrentStopState = _projection.Stops.First(); + } + + public void Arrive(int stopId) + { + if (IsComplete()) + { + throw new InvalidOperationException("Shipment is already complete."); + } + + if (_projection.CurrentStopState.StopId != stopId) + { + throw new InvalidOperationException("Stop does not exist or is not the current stop."); + } + + if (_projection.CurrentStopState.Status != StopStatus.InTransit) + { + throw new InvalidOperationException("Stop has already arrived."); + } + + var arrived = new Arrived(stopId, DateTime.UtcNow); + Apply(arrived); + } + + private void Apply(Arrived arrived) + { + _projection.CurrentStopState.Status = StopStatus.Arrived; + } + + public void Pickup(int stopId) + { + if (IsComplete()) + { + throw new InvalidOperationException("Shipment is already complete."); + } + + if (_projection.CurrentStopState.StopId != stopId) + { + throw new InvalidOperationException("Stop does not exist or is not the current stop."); + } + + if (_projection.CurrentStopState.Type != StopType.Pickup) + { + throw new InvalidOperationException("Stop is not a pickup."); + } + + if (_projection.CurrentStopState.Status == StopStatus.Departed) + { + throw new InvalidOperationException("Stop has already departed."); + } + + if (_projection.CurrentStopState.Status == StopStatus.InTransit) + { + throw new InvalidOperationException("Stop hasn't arrived yet."); + } + + var pickedUp = new PickedUp(stopId, DateTime.UtcNow); + Apply(pickedUp); + } + + private void Apply(PickedUp pickedUp) + { + _projection.CurrentStopState = _projection.Stops.SkipWhile(x => x.Status != StopStatus.InTransit).FirstOrDefault(); + } + + public void Deliver(int stopId) + { + if (IsComplete()) + { + throw new InvalidOperationException("Shipment is already complete."); + } + + if (_projection.CurrentStopState.StopId != stopId) + { + throw new InvalidOperationException("Stop does not exist or is not the current stop."); + } + + if (_projection.CurrentStopState.Type != StopType.Delivery) + { + throw new InvalidOperationException("Stop is not a delivery."); + } + + if (_projection.CurrentStopState.Status == StopStatus.Departed) + { + throw new InvalidOperationException("Stop has already departed."); + } + + if (_projection.CurrentStopState.Status == StopStatus.InTransit) + { + throw new InvalidOperationException("Stop hasn't arrived yet."); + } + + var delivered = new Delivered(stopId, DateTime.UtcNow); + Apply(delivered); + } + + private void Apply(Delivered delivered) + { + _projection.CurrentStopState = _projection.Stops.SkipWhile(x => x.Status != StopStatus.InTransit).FirstOrDefault(); + } + + public bool IsComplete() + { + return _projection.CurrentStopState == null; + } + } + + public record ShipmentStop(int StopId, StopType StopType, int Sequence); +} diff --git a/EventSourced/ShipmentState.cs b/EventSourced/ShipmentState.cs new file mode 100644 index 0000000..525b6ba --- /dev/null +++ b/EventSourced/ShipmentState.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace AggregateConsistencyBoundary.EventSourced +{ + public class ShipmentState + { + public List Stops { get; set; } + public StopState CurrentStopState { get; set; } + } + + public class StopState + { + public StopState(int stopId, StopType type, StopStatus status) + { + StopId = stopId; + Type = type; + Status = status; + } + + public int StopId { get; set; } + public StopType Type { get; set; } + public StopStatus Status { get; set; } + } +} \ No newline at end of file diff --git a/EventSourced/Tests.cs b/EventSourced/Tests.cs new file mode 100644 index 0000000..cc50083 --- /dev/null +++ b/EventSourced/Tests.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using Shouldly; +using Xunit; + +namespace AggregateConsistencyBoundary.EventSourced +{ + public class Tests + { + private readonly ShipmentAggregateRoot _shipmentAggregateRootAggregateRoot; + + public Tests() + { + var stops = new List + { + new ShipmentStop(1, StopType.Pickup, 1), + new ShipmentStop(2, StopType.Delivery, 2) + }; + _shipmentAggregateRootAggregateRoot = new ShipmentAggregateRoot(); + _shipmentAggregateRootAggregateRoot.Dispatch(1, stops); + } + + [Fact] + public void CompleteShipment() + { + _shipmentAggregateRootAggregateRoot.Arrive(1); + _shipmentAggregateRootAggregateRoot.Pickup(1); + _shipmentAggregateRootAggregateRoot.Arrive(2); + _shipmentAggregateRootAggregateRoot.Deliver(2); + _shipmentAggregateRootAggregateRoot.IsComplete().ShouldBeTrue(); + } + + [Fact] + public void CannotPickupWithoutArriving() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Pickup(1), "Stop hasn't arrived yet."); + } + + [Fact] + public void CannotDeliverWithoutArriving() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Deliver(2), "Stop hasn't arrived yet."); + } + + [Fact] + public void CannotPickupAtDelivery() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Pickup(2), "Stop is not a delivery."); + } + + [Fact] + public void CannotDeliverAtPickup() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Deliver(1), "Stop is not a pickup."); + } + + [Fact] + public void ArriveStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Arrive(0), "Stop does not exist."); + } + + [Fact] + public void PickupStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Pickup(0), "Stop does not exist."); + } + + [Fact] + public void DeliverStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Deliver(0), "Stop does not exist."); + } + + [Fact] + public void ArriveNonDepartedStops() + { + _shipmentAggregateRootAggregateRoot.Arrive(1); + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Arrive(2), "Previous stops have not departed."); + } + + [Fact] + public void AlreadyArrived() + { + _shipmentAggregateRootAggregateRoot.Arrive(1); + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Arrive(1), "Stop has already arrived."); + } + + [Fact] + public void AlreadyPickedUp() + { + _shipmentAggregateRootAggregateRoot.Arrive(1); + _shipmentAggregateRootAggregateRoot.Pickup(1); + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Pickup(1), "Stop has already departed."); + } + + [Fact] + public void AlreadyDelivered() + { + _shipmentAggregateRootAggregateRoot.Arrive(1); + _shipmentAggregateRootAggregateRoot.Pickup(1); + _shipmentAggregateRootAggregateRoot.Arrive(2); + _shipmentAggregateRootAggregateRoot.Deliver(2); + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Deliver(2), "Stop has already departed."); + } + } +} \ No newline at end of file diff --git a/EventSourcing-ShipmentAggregate.csproj b/EventSourcing-ShipmentAggregate.csproj new file mode 100644 index 0000000..a71d4c2 --- /dev/null +++ b/EventSourcing-ShipmentAggregate.csproj @@ -0,0 +1,12 @@ + + + + net5.0 + + + + + + + + diff --git a/README.md b/README.md index 4ccd1a7..acacb6b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ -EventSourcing-ShipmentAggregate +Event Sourcing: Shipment Aggregate Comparison =============== +https://youtu.be/C5TmLMZ4fXo diff --git a/ShipmentAggregateRoot.cs b/ShipmentAggregateRoot.cs new file mode 100644 index 0000000..be51139 --- /dev/null +++ b/ShipmentAggregateRoot.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace AggregateConsistencyBoundary +{ + public class ShipmentAggregateRoot + { + public int ShipmentId { get; set; } + public Address Customer { get; set; } + public DateTime DateTime { get; set; } + public List Stops { get; set; } = new(); + + public void Arrive(int stopId) + { + var currentStop = Stops.SingleOrDefault(x => x.StopId == stopId); + if (currentStop == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + var previousStopsAreNotDeparted = Stops.Any(x => x.Sequence < currentStop.Sequence && x.Status != StopStatus.Departed); + if (previousStopsAreNotDeparted) + { + throw new InvalidOperationException("Previous stops have not departed."); + } + + currentStop.Arrive(); + } + + public void Pickup(int stopId) + { + var currentStop = Stops.SingleOrDefault(x => x.StopId == stopId); + if (currentStop == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (currentStop.Type != StopType.Pickup) + { + throw new InvalidOperationException("Stop is not a pickup."); + } + + currentStop.Depart(); + } + + public void Deliver(int stopId) + { + var currentStop = Stops.SingleOrDefault(x => x.StopId == stopId); + if (currentStop == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (currentStop.Type != StopType.Delivery) + { + throw new InvalidOperationException("Stop is not a delivery."); + } + + currentStop.Depart(); + } + + public bool IsComplete() + { + return Stops.All(x => x.Status == StopStatus.Departed); + } + } + + public record Address(string Street, string City, string Postal, string Country); +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea5ebf9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,205 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.sln.docstates + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +x64/ +bld/ +[Bb]in/ +[Oo]bj/ + +# Roslyn cache directories +*.ide/ + +# AWS +*.aws-sam/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +#NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opensdf +*.sdf +*.cachefile + +# Visual Studio profiler +*.psess +*.vsp +*.vspx + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding addin-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +## TODO: Comment the next line if you want to checkin your +## web deploy settings but do note that will include unencrypted +## passwords +#*.pubxml + +# NuGet Packages Directory +#packages/* +## TODO: If the tool you use requires repositories.config +## uncomment the next line +#!packages/repositories.config + +# Enable "build/" folder in the NuGet Packages folder since +# NuGet packages use it for MSBuild targets. +# This line needs to be after the ignore of the build folder +# (and the packages folder if the line above has been uncommented) +#!packages/build/ + +!src/packages/**/*.dll +!src/packages/**/*.pdb + +# Windows Azure Build Output +csx/ +*.build.csdef + +# Windows Store app package directory +AppPackages/ + +# Others +*.Cache +ClientBin/ +[Ss]tyle[Cc]op.* +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.pfx +*.publishsettings +node_modules/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# LightSwitch generated files +GeneratedArtifacts/ +_Pvt_Extensions/ +ModelManifest.xml +src/packages/EventStore.Client.3.3.1/lib/net40/EventStore.ClientAPI.xml + +# JetBrains Rider +.idea/ +*.sln.iml + +# Vagrant VM files +.vagrant +vagrant/dbv/data/meta/revision + +# Visual Studio 2015 cache/options directory +.vs/ \ No newline at end of file diff --git a/EventSourced/Events.cs b/EventSourced/Events.cs new file mode 100644 index 0000000..a62dcd4 --- /dev/null +++ b/EventSourced/Events.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; + +namespace AggregateConsistencyBoundary.EventSourced +{ + public interface IEvent { } + + public record Dispatched(int ShipmentId, IEnumerable Stops, DateTime Start) : IEvent; + + public record Arrived(int StopId, DateTime Arrival) : IEvent; + + public record PickedUp(int StopId, DateTime Loaded) : IEvent; + + public record Delivered(int StopId, DateTime Delivery) : IEvent; +} \ No newline at end of file diff --git a/EventSourced/ShipmentAggregateRoot.cs b/EventSourced/ShipmentAggregateRoot.cs new file mode 100644 index 0000000..63acf6f --- /dev/null +++ b/EventSourced/ShipmentAggregateRoot.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace AggregateConsistencyBoundary.EventSourced +{ + public class ShipmentAggregateRoot + { + private readonly ShipmentState _projection = new(); + + public void Dispatch(int shipmentId, IEnumerable stops) + { + var dispatched = new Dispatched(shipmentId, stops, DateTime.UtcNow); + Apply(dispatched); + } + + private void Apply(Dispatched dispatched) + { + _projection.Stops = dispatched.Stops.OrderBy(x => x.Sequence).Select(x => new StopState(x.StopId, x.StopType, StopStatus.InTransit)).ToList(); + _projection.CurrentStopState = _projection.Stops.First(); + } + + public void Arrive(int stopId) + { + if (IsComplete()) + { + throw new InvalidOperationException("Shipment is already complete."); + } + + if (_projection.CurrentStopState.StopId != stopId) + { + throw new InvalidOperationException("Stop does not exist or is not the current stop."); + } + + if (_projection.CurrentStopState.Status != StopStatus.InTransit) + { + throw new InvalidOperationException("Stop has already arrived."); + } + + var arrived = new Arrived(stopId, DateTime.UtcNow); + Apply(arrived); + } + + private void Apply(Arrived arrived) + { + _projection.CurrentStopState.Status = StopStatus.Arrived; + } + + public void Pickup(int stopId) + { + if (IsComplete()) + { + throw new InvalidOperationException("Shipment is already complete."); + } + + if (_projection.CurrentStopState.StopId != stopId) + { + throw new InvalidOperationException("Stop does not exist or is not the current stop."); + } + + if (_projection.CurrentStopState.Type != StopType.Pickup) + { + throw new InvalidOperationException("Stop is not a pickup."); + } + + if (_projection.CurrentStopState.Status == StopStatus.Departed) + { + throw new InvalidOperationException("Stop has already departed."); + } + + if (_projection.CurrentStopState.Status == StopStatus.InTransit) + { + throw new InvalidOperationException("Stop hasn't arrived yet."); + } + + var pickedUp = new PickedUp(stopId, DateTime.UtcNow); + Apply(pickedUp); + } + + private void Apply(PickedUp pickedUp) + { + _projection.CurrentStopState = _projection.Stops.SkipWhile(x => x.Status != StopStatus.InTransit).FirstOrDefault(); + } + + public void Deliver(int stopId) + { + if (IsComplete()) + { + throw new InvalidOperationException("Shipment is already complete."); + } + + if (_projection.CurrentStopState.StopId != stopId) + { + throw new InvalidOperationException("Stop does not exist or is not the current stop."); + } + + if (_projection.CurrentStopState.Type != StopType.Delivery) + { + throw new InvalidOperationException("Stop is not a delivery."); + } + + if (_projection.CurrentStopState.Status == StopStatus.Departed) + { + throw new InvalidOperationException("Stop has already departed."); + } + + if (_projection.CurrentStopState.Status == StopStatus.InTransit) + { + throw new InvalidOperationException("Stop hasn't arrived yet."); + } + + var delivered = new Delivered(stopId, DateTime.UtcNow); + Apply(delivered); + } + + private void Apply(Delivered delivered) + { + _projection.CurrentStopState = _projection.Stops.SkipWhile(x => x.Status != StopStatus.InTransit).FirstOrDefault(); + } + + public bool IsComplete() + { + return _projection.CurrentStopState == null; + } + } + + public record ShipmentStop(int StopId, StopType StopType, int Sequence); +} diff --git a/EventSourced/ShipmentState.cs b/EventSourced/ShipmentState.cs new file mode 100644 index 0000000..525b6ba --- /dev/null +++ b/EventSourced/ShipmentState.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace AggregateConsistencyBoundary.EventSourced +{ + public class ShipmentState + { + public List Stops { get; set; } + public StopState CurrentStopState { get; set; } + } + + public class StopState + { + public StopState(int stopId, StopType type, StopStatus status) + { + StopId = stopId; + Type = type; + Status = status; + } + + public int StopId { get; set; } + public StopType Type { get; set; } + public StopStatus Status { get; set; } + } +} \ No newline at end of file diff --git a/EventSourced/Tests.cs b/EventSourced/Tests.cs new file mode 100644 index 0000000..cc50083 --- /dev/null +++ b/EventSourced/Tests.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using Shouldly; +using Xunit; + +namespace AggregateConsistencyBoundary.EventSourced +{ + public class Tests + { + private readonly ShipmentAggregateRoot _shipmentAggregateRootAggregateRoot; + + public Tests() + { + var stops = new List + { + new ShipmentStop(1, StopType.Pickup, 1), + new ShipmentStop(2, StopType.Delivery, 2) + }; + _shipmentAggregateRootAggregateRoot = new ShipmentAggregateRoot(); + _shipmentAggregateRootAggregateRoot.Dispatch(1, stops); + } + + [Fact] + public void CompleteShipment() + { + _shipmentAggregateRootAggregateRoot.Arrive(1); + _shipmentAggregateRootAggregateRoot.Pickup(1); + _shipmentAggregateRootAggregateRoot.Arrive(2); + _shipmentAggregateRootAggregateRoot.Deliver(2); + _shipmentAggregateRootAggregateRoot.IsComplete().ShouldBeTrue(); + } + + [Fact] + public void CannotPickupWithoutArriving() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Pickup(1), "Stop hasn't arrived yet."); + } + + [Fact] + public void CannotDeliverWithoutArriving() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Deliver(2), "Stop hasn't arrived yet."); + } + + [Fact] + public void CannotPickupAtDelivery() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Pickup(2), "Stop is not a delivery."); + } + + [Fact] + public void CannotDeliverAtPickup() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Deliver(1), "Stop is not a pickup."); + } + + [Fact] + public void ArriveStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Arrive(0), "Stop does not exist."); + } + + [Fact] + public void PickupStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Pickup(0), "Stop does not exist."); + } + + [Fact] + public void DeliverStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Deliver(0), "Stop does not exist."); + } + + [Fact] + public void ArriveNonDepartedStops() + { + _shipmentAggregateRootAggregateRoot.Arrive(1); + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Arrive(2), "Previous stops have not departed."); + } + + [Fact] + public void AlreadyArrived() + { + _shipmentAggregateRootAggregateRoot.Arrive(1); + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Arrive(1), "Stop has already arrived."); + } + + [Fact] + public void AlreadyPickedUp() + { + _shipmentAggregateRootAggregateRoot.Arrive(1); + _shipmentAggregateRootAggregateRoot.Pickup(1); + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Pickup(1), "Stop has already departed."); + } + + [Fact] + public void AlreadyDelivered() + { + _shipmentAggregateRootAggregateRoot.Arrive(1); + _shipmentAggregateRootAggregateRoot.Pickup(1); + _shipmentAggregateRootAggregateRoot.Arrive(2); + _shipmentAggregateRootAggregateRoot.Deliver(2); + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Deliver(2), "Stop has already departed."); + } + } +} \ No newline at end of file diff --git a/EventSourcing-ShipmentAggregate.csproj b/EventSourcing-ShipmentAggregate.csproj new file mode 100644 index 0000000..a71d4c2 --- /dev/null +++ b/EventSourcing-ShipmentAggregate.csproj @@ -0,0 +1,12 @@ + + + + net5.0 + + + + + + + + diff --git a/README.md b/README.md index 4ccd1a7..acacb6b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ -EventSourcing-ShipmentAggregate +Event Sourcing: Shipment Aggregate Comparison =============== +https://youtu.be/C5TmLMZ4fXo diff --git a/ShipmentAggregateRoot.cs b/ShipmentAggregateRoot.cs new file mode 100644 index 0000000..be51139 --- /dev/null +++ b/ShipmentAggregateRoot.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace AggregateConsistencyBoundary +{ + public class ShipmentAggregateRoot + { + public int ShipmentId { get; set; } + public Address Customer { get; set; } + public DateTime DateTime { get; set; } + public List Stops { get; set; } = new(); + + public void Arrive(int stopId) + { + var currentStop = Stops.SingleOrDefault(x => x.StopId == stopId); + if (currentStop == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + var previousStopsAreNotDeparted = Stops.Any(x => x.Sequence < currentStop.Sequence && x.Status != StopStatus.Departed); + if (previousStopsAreNotDeparted) + { + throw new InvalidOperationException("Previous stops have not departed."); + } + + currentStop.Arrive(); + } + + public void Pickup(int stopId) + { + var currentStop = Stops.SingleOrDefault(x => x.StopId == stopId); + if (currentStop == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (currentStop.Type != StopType.Pickup) + { + throw new InvalidOperationException("Stop is not a pickup."); + } + + currentStop.Depart(); + } + + public void Deliver(int stopId) + { + var currentStop = Stops.SingleOrDefault(x => x.StopId == stopId); + if (currentStop == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (currentStop.Type != StopType.Delivery) + { + throw new InvalidOperationException("Stop is not a delivery."); + } + + currentStop.Depart(); + } + + public bool IsComplete() + { + return Stops.All(x => x.Status == StopStatus.Departed); + } + } + + public record Address(string Street, string City, string Postal, string Country); +} diff --git a/Stop.cs b/Stop.cs new file mode 100644 index 0000000..0181e14 --- /dev/null +++ b/Stop.cs @@ -0,0 +1,54 @@ +using System; + +namespace AggregateConsistencyBoundary +{ + public class Stop + { + public int StopId { get; set; } + public StopType Type { get; set; } + public StopStatus Status { get; set; } + public int Sequence { get; set; } + public Address Address { get; set; } + public DateTime Scheduled { get; set; } + public DateTime? Departed { get; set; } + + public void Arrive() + { + if (Status != StopStatus.InTransit) + { + throw new InvalidOperationException("Stop has already arrived."); + } + + Status = StopStatus.Arrived; + } + + public void Depart() + { + if (Status == StopStatus.Departed) + { + throw new InvalidOperationException("Stop has already departed."); + } + + if (Status == StopStatus.InTransit) + { + throw new InvalidOperationException("Stop hasn't arrived yet."); + } + + Status = StopStatus.Departed; + Departed = DateTime.UtcNow; + } + } + + public enum StopType + { + Pickup, + Delivery + } + + public enum StopStatus + { + InTransit, + Arrived, + Departed + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea5ebf9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,205 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.sln.docstates + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +x64/ +bld/ +[Bb]in/ +[Oo]bj/ + +# Roslyn cache directories +*.ide/ + +# AWS +*.aws-sam/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +#NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opensdf +*.sdf +*.cachefile + +# Visual Studio profiler +*.psess +*.vsp +*.vspx + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding addin-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +## TODO: Comment the next line if you want to checkin your +## web deploy settings but do note that will include unencrypted +## passwords +#*.pubxml + +# NuGet Packages Directory +#packages/* +## TODO: If the tool you use requires repositories.config +## uncomment the next line +#!packages/repositories.config + +# Enable "build/" folder in the NuGet Packages folder since +# NuGet packages use it for MSBuild targets. +# This line needs to be after the ignore of the build folder +# (and the packages folder if the line above has been uncommented) +#!packages/build/ + +!src/packages/**/*.dll +!src/packages/**/*.pdb + +# Windows Azure Build Output +csx/ +*.build.csdef + +# Windows Store app package directory +AppPackages/ + +# Others +*.Cache +ClientBin/ +[Ss]tyle[Cc]op.* +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.pfx +*.publishsettings +node_modules/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# LightSwitch generated files +GeneratedArtifacts/ +_Pvt_Extensions/ +ModelManifest.xml +src/packages/EventStore.Client.3.3.1/lib/net40/EventStore.ClientAPI.xml + +# JetBrains Rider +.idea/ +*.sln.iml + +# Vagrant VM files +.vagrant +vagrant/dbv/data/meta/revision + +# Visual Studio 2015 cache/options directory +.vs/ \ No newline at end of file diff --git a/EventSourced/Events.cs b/EventSourced/Events.cs new file mode 100644 index 0000000..a62dcd4 --- /dev/null +++ b/EventSourced/Events.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; + +namespace AggregateConsistencyBoundary.EventSourced +{ + public interface IEvent { } + + public record Dispatched(int ShipmentId, IEnumerable Stops, DateTime Start) : IEvent; + + public record Arrived(int StopId, DateTime Arrival) : IEvent; + + public record PickedUp(int StopId, DateTime Loaded) : IEvent; + + public record Delivered(int StopId, DateTime Delivery) : IEvent; +} \ No newline at end of file diff --git a/EventSourced/ShipmentAggregateRoot.cs b/EventSourced/ShipmentAggregateRoot.cs new file mode 100644 index 0000000..63acf6f --- /dev/null +++ b/EventSourced/ShipmentAggregateRoot.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace AggregateConsistencyBoundary.EventSourced +{ + public class ShipmentAggregateRoot + { + private readonly ShipmentState _projection = new(); + + public void Dispatch(int shipmentId, IEnumerable stops) + { + var dispatched = new Dispatched(shipmentId, stops, DateTime.UtcNow); + Apply(dispatched); + } + + private void Apply(Dispatched dispatched) + { + _projection.Stops = dispatched.Stops.OrderBy(x => x.Sequence).Select(x => new StopState(x.StopId, x.StopType, StopStatus.InTransit)).ToList(); + _projection.CurrentStopState = _projection.Stops.First(); + } + + public void Arrive(int stopId) + { + if (IsComplete()) + { + throw new InvalidOperationException("Shipment is already complete."); + } + + if (_projection.CurrentStopState.StopId != stopId) + { + throw new InvalidOperationException("Stop does not exist or is not the current stop."); + } + + if (_projection.CurrentStopState.Status != StopStatus.InTransit) + { + throw new InvalidOperationException("Stop has already arrived."); + } + + var arrived = new Arrived(stopId, DateTime.UtcNow); + Apply(arrived); + } + + private void Apply(Arrived arrived) + { + _projection.CurrentStopState.Status = StopStatus.Arrived; + } + + public void Pickup(int stopId) + { + if (IsComplete()) + { + throw new InvalidOperationException("Shipment is already complete."); + } + + if (_projection.CurrentStopState.StopId != stopId) + { + throw new InvalidOperationException("Stop does not exist or is not the current stop."); + } + + if (_projection.CurrentStopState.Type != StopType.Pickup) + { + throw new InvalidOperationException("Stop is not a pickup."); + } + + if (_projection.CurrentStopState.Status == StopStatus.Departed) + { + throw new InvalidOperationException("Stop has already departed."); + } + + if (_projection.CurrentStopState.Status == StopStatus.InTransit) + { + throw new InvalidOperationException("Stop hasn't arrived yet."); + } + + var pickedUp = new PickedUp(stopId, DateTime.UtcNow); + Apply(pickedUp); + } + + private void Apply(PickedUp pickedUp) + { + _projection.CurrentStopState = _projection.Stops.SkipWhile(x => x.Status != StopStatus.InTransit).FirstOrDefault(); + } + + public void Deliver(int stopId) + { + if (IsComplete()) + { + throw new InvalidOperationException("Shipment is already complete."); + } + + if (_projection.CurrentStopState.StopId != stopId) + { + throw new InvalidOperationException("Stop does not exist or is not the current stop."); + } + + if (_projection.CurrentStopState.Type != StopType.Delivery) + { + throw new InvalidOperationException("Stop is not a delivery."); + } + + if (_projection.CurrentStopState.Status == StopStatus.Departed) + { + throw new InvalidOperationException("Stop has already departed."); + } + + if (_projection.CurrentStopState.Status == StopStatus.InTransit) + { + throw new InvalidOperationException("Stop hasn't arrived yet."); + } + + var delivered = new Delivered(stopId, DateTime.UtcNow); + Apply(delivered); + } + + private void Apply(Delivered delivered) + { + _projection.CurrentStopState = _projection.Stops.SkipWhile(x => x.Status != StopStatus.InTransit).FirstOrDefault(); + } + + public bool IsComplete() + { + return _projection.CurrentStopState == null; + } + } + + public record ShipmentStop(int StopId, StopType StopType, int Sequence); +} diff --git a/EventSourced/ShipmentState.cs b/EventSourced/ShipmentState.cs new file mode 100644 index 0000000..525b6ba --- /dev/null +++ b/EventSourced/ShipmentState.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace AggregateConsistencyBoundary.EventSourced +{ + public class ShipmentState + { + public List Stops { get; set; } + public StopState CurrentStopState { get; set; } + } + + public class StopState + { + public StopState(int stopId, StopType type, StopStatus status) + { + StopId = stopId; + Type = type; + Status = status; + } + + public int StopId { get; set; } + public StopType Type { get; set; } + public StopStatus Status { get; set; } + } +} \ No newline at end of file diff --git a/EventSourced/Tests.cs b/EventSourced/Tests.cs new file mode 100644 index 0000000..cc50083 --- /dev/null +++ b/EventSourced/Tests.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using Shouldly; +using Xunit; + +namespace AggregateConsistencyBoundary.EventSourced +{ + public class Tests + { + private readonly ShipmentAggregateRoot _shipmentAggregateRootAggregateRoot; + + public Tests() + { + var stops = new List + { + new ShipmentStop(1, StopType.Pickup, 1), + new ShipmentStop(2, StopType.Delivery, 2) + }; + _shipmentAggregateRootAggregateRoot = new ShipmentAggregateRoot(); + _shipmentAggregateRootAggregateRoot.Dispatch(1, stops); + } + + [Fact] + public void CompleteShipment() + { + _shipmentAggregateRootAggregateRoot.Arrive(1); + _shipmentAggregateRootAggregateRoot.Pickup(1); + _shipmentAggregateRootAggregateRoot.Arrive(2); + _shipmentAggregateRootAggregateRoot.Deliver(2); + _shipmentAggregateRootAggregateRoot.IsComplete().ShouldBeTrue(); + } + + [Fact] + public void CannotPickupWithoutArriving() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Pickup(1), "Stop hasn't arrived yet."); + } + + [Fact] + public void CannotDeliverWithoutArriving() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Deliver(2), "Stop hasn't arrived yet."); + } + + [Fact] + public void CannotPickupAtDelivery() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Pickup(2), "Stop is not a delivery."); + } + + [Fact] + public void CannotDeliverAtPickup() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Deliver(1), "Stop is not a pickup."); + } + + [Fact] + public void ArriveStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Arrive(0), "Stop does not exist."); + } + + [Fact] + public void PickupStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Pickup(0), "Stop does not exist."); + } + + [Fact] + public void DeliverStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Deliver(0), "Stop does not exist."); + } + + [Fact] + public void ArriveNonDepartedStops() + { + _shipmentAggregateRootAggregateRoot.Arrive(1); + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Arrive(2), "Previous stops have not departed."); + } + + [Fact] + public void AlreadyArrived() + { + _shipmentAggregateRootAggregateRoot.Arrive(1); + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Arrive(1), "Stop has already arrived."); + } + + [Fact] + public void AlreadyPickedUp() + { + _shipmentAggregateRootAggregateRoot.Arrive(1); + _shipmentAggregateRootAggregateRoot.Pickup(1); + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Pickup(1), "Stop has already departed."); + } + + [Fact] + public void AlreadyDelivered() + { + _shipmentAggregateRootAggregateRoot.Arrive(1); + _shipmentAggregateRootAggregateRoot.Pickup(1); + _shipmentAggregateRootAggregateRoot.Arrive(2); + _shipmentAggregateRootAggregateRoot.Deliver(2); + Should.Throw(() => _shipmentAggregateRootAggregateRoot.Deliver(2), "Stop has already departed."); + } + } +} \ No newline at end of file diff --git a/EventSourcing-ShipmentAggregate.csproj b/EventSourcing-ShipmentAggregate.csproj new file mode 100644 index 0000000..a71d4c2 --- /dev/null +++ b/EventSourcing-ShipmentAggregate.csproj @@ -0,0 +1,12 @@ + + + + net5.0 + + + + + + + + diff --git a/README.md b/README.md index 4ccd1a7..acacb6b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ -EventSourcing-ShipmentAggregate +Event Sourcing: Shipment Aggregate Comparison =============== +https://youtu.be/C5TmLMZ4fXo diff --git a/ShipmentAggregateRoot.cs b/ShipmentAggregateRoot.cs new file mode 100644 index 0000000..be51139 --- /dev/null +++ b/ShipmentAggregateRoot.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace AggregateConsistencyBoundary +{ + public class ShipmentAggregateRoot + { + public int ShipmentId { get; set; } + public Address Customer { get; set; } + public DateTime DateTime { get; set; } + public List Stops { get; set; } = new(); + + public void Arrive(int stopId) + { + var currentStop = Stops.SingleOrDefault(x => x.StopId == stopId); + if (currentStop == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + var previousStopsAreNotDeparted = Stops.Any(x => x.Sequence < currentStop.Sequence && x.Status != StopStatus.Departed); + if (previousStopsAreNotDeparted) + { + throw new InvalidOperationException("Previous stops have not departed."); + } + + currentStop.Arrive(); + } + + public void Pickup(int stopId) + { + var currentStop = Stops.SingleOrDefault(x => x.StopId == stopId); + if (currentStop == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (currentStop.Type != StopType.Pickup) + { + throw new InvalidOperationException("Stop is not a pickup."); + } + + currentStop.Depart(); + } + + public void Deliver(int stopId) + { + var currentStop = Stops.SingleOrDefault(x => x.StopId == stopId); + if (currentStop == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (currentStop.Type != StopType.Delivery) + { + throw new InvalidOperationException("Stop is not a delivery."); + } + + currentStop.Depart(); + } + + public bool IsComplete() + { + return Stops.All(x => x.Status == StopStatus.Departed); + } + } + + public record Address(string Street, string City, string Postal, string Country); +} diff --git a/Stop.cs b/Stop.cs new file mode 100644 index 0000000..0181e14 --- /dev/null +++ b/Stop.cs @@ -0,0 +1,54 @@ +using System; + +namespace AggregateConsistencyBoundary +{ + public class Stop + { + public int StopId { get; set; } + public StopType Type { get; set; } + public StopStatus Status { get; set; } + public int Sequence { get; set; } + public Address Address { get; set; } + public DateTime Scheduled { get; set; } + public DateTime? Departed { get; set; } + + public void Arrive() + { + if (Status != StopStatus.InTransit) + { + throw new InvalidOperationException("Stop has already arrived."); + } + + Status = StopStatus.Arrived; + } + + public void Depart() + { + if (Status == StopStatus.Departed) + { + throw new InvalidOperationException("Stop has already departed."); + } + + if (Status == StopStatus.InTransit) + { + throw new InvalidOperationException("Stop hasn't arrived yet."); + } + + Status = StopStatus.Departed; + Departed = DateTime.UtcNow; + } + } + + public enum StopType + { + Pickup, + Delivery + } + + public enum StopStatus + { + InTransit, + Arrived, + Departed + } +} \ No newline at end of file diff --git a/Tests.cs b/Tests.cs new file mode 100644 index 0000000..85697f1 --- /dev/null +++ b/Tests.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using Shouldly; +using Xunit; + +namespace AggregateConsistencyBoundary +{ + public class Tests + { + private readonly ShipmentAggregateRoot _shipmentAggregateRoot; + + public Tests() + { + _shipmentAggregateRoot = new ShipmentAggregateRoot + { + Stops = new List + { + new() + { + StopId = 1, + Sequence = 1, + Type = StopType.Pickup + }, + new() + { + StopId = 2, + Sequence = 2, + Type = StopType.Delivery + } + } + }; + } + + [Fact] + public void CompleteShipment() + { + _shipmentAggregateRoot.Arrive(1); + _shipmentAggregateRoot.Pickup(1); + _shipmentAggregateRoot.Arrive(2); + _shipmentAggregateRoot.Deliver(2); + _shipmentAggregateRoot.IsComplete().ShouldBeTrue(); + } + + [Fact] + public void CannotPickupWithoutArriving() + { + Should.Throw(() => _shipmentAggregateRoot.Pickup(1), "Stop hasn't arrived yet."); + } + + [Fact] + public void CannotDeliverWithoutArriving() + { + Should.Throw(() => _shipmentAggregateRoot.Deliver(2), "Stop hasn't arrived yet."); + } + + [Fact] + public void CannotPickupAtDelivery() + { + Should.Throw(() => _shipmentAggregateRoot.Pickup(2), "Stop is not a delivery."); + } + + [Fact] + public void CannotDeliverAtPickup() + { + Should.Throw(() => _shipmentAggregateRoot.Deliver(1), "Stop is not a pickup."); + } + + [Fact] + public void ArriveStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRoot.Arrive(0), "Stop does not exist."); + } + + [Fact] + public void PickupStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRoot.Pickup(0), "Stop does not exist."); + } + + [Fact] + public void DeliverStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRoot.Deliver(0), "Stop does not exist."); + } + + [Fact] + public void ArriveNonDepartedStops() + { + _shipmentAggregateRoot.Arrive(1); + Should.Throw(() => _shipmentAggregateRoot.Arrive(2), "Previous stops have not departed."); + } + + [Fact] + public void AlreadyArrived() + { + _shipmentAggregateRoot.Arrive(1); + Should.Throw(() => _shipmentAggregateRoot.Arrive(1), "Stop has already arrived."); + } + + [Fact] + public void AlreadyPickedUp() + { + _shipmentAggregateRoot.Arrive(1); + _shipmentAggregateRoot.Pickup(1); + Should.Throw(() => _shipmentAggregateRoot.Pickup(1), "Stop has already departed."); + } + + [Fact] + public void AlreadyDelivered() + { + _shipmentAggregateRoot.Arrive(1); + _shipmentAggregateRoot.Pickup(1); + _shipmentAggregateRoot.Arrive(2); + _shipmentAggregateRoot.Deliver(2); + Should.Throw(() => _shipmentAggregateRoot.Deliver(2), "Stop has already departed."); + } + } +} \ No newline at end of file