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/Events.cs b/Events.cs new file mode 100644 index 0000000..161233b --- /dev/null +++ b/Events.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; + +namespace Demo.EventSourced +{ + public interface IEvent { } + + public record Dispatched(int ShipmentId, IEnumerable Stops, DateTime Start) : IEvent; + + public record Arrived(int StopId, DateTime Arrival) : IEvent; + + public record ArrivedLate(int StopId, TimeSpan Delay) : 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/Events.cs b/Events.cs new file mode 100644 index 0000000..161233b --- /dev/null +++ b/Events.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; + +namespace Demo.EventSourced +{ + public interface IEvent { } + + public record Dispatched(int ShipmentId, IEnumerable Stops, DateTime Start) : IEvent; + + public record Arrived(int StopId, DateTime Arrival) : IEvent; + + public record ArrivedLate(int StopId, TimeSpan Delay) : 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/ShipmentAggregateRoot.cs b/ShipmentAggregateRoot.cs new file mode 100644 index 0000000..898f6dc --- /dev/null +++ b/ShipmentAggregateRoot.cs @@ -0,0 +1,203 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Demo.EventSourced; + +namespace Demo +{ + public class ShipmentAggregateRoot + { + private readonly SortedList _stops = new(); + private readonly List _events = new(); + + private ShipmentAggregateRoot(IReadOnlyList stops) + { + for(var x = 0; x < stops.Count; x++) + { + _stops.Add(x, stops[x]); + } + } + + public static ShipmentAggregateRoot Factory(PickupStop pickup, DeliveryStop delivery) + { + return new ShipmentAggregateRoot(new Stop[] { pickup, delivery }); + } + + public static ShipmentAggregateRoot Factory(Stop[] stops) + { + if (stops.Length < 2) + { + throw new InvalidOperationException("Shipment requires at least 2 stops."); + } + + if (stops.First() is not PickupStop) + { + throw new InvalidOperationException("First stop must be a Pickup"); + } + + if (stops.Last() is not DeliveryStop) + { + throw new InvalidOperationException("first stop must be a Pickup"); + } + + return new ShipmentAggregateRoot(stops); + } + + public List GetUncommittedEvents() + { + var evnts = new List(_events); + _events.Clear(); + return evnts; + } + + public void Arrive(int stopId, DateTime arrived) + { + var currentStop = _stops.SingleOrDefault(x => x.Value.StopId == stopId); + if (currentStop.Value == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + var previousStopsAreNotDeparted = _stops.Any(x => x.Key < currentStop.Key && x.Value.Status != StopStatus.Departed); + if (previousStopsAreNotDeparted) + { + throw new InvalidOperationException("Previous stops have not departed."); + } + + currentStop.Value.Arrive(arrived); + + _events.Add(new Arrived(stopId, arrived)); + + var arrivedToScheduled = arrived - currentStop.Value.Scheduled; + if (arrivedToScheduled.TotalHours > 0) + { + _events.Add(new ArrivedLate(stopId, arrivedToScheduled)); + } + } + + public void Pickup(int stopId, DateTime departed) + { + var currentStop = _stops.SingleOrDefault(x => x.Value.StopId == stopId); + if (currentStop.Value == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (currentStop.Value is not PickupStop) + { + throw new InvalidOperationException("Stop is not a pickup."); + } + + if (currentStop.Value.Status != StopStatus.Arrived) + { + Arrive(stopId, departed); + } + + currentStop.Value.Depart(departed); + _events.Add(new PickedUp(stopId, departed)); + } + + public void Deliver(int stopId, DateTime departed) + { + var currentStop = _stops.SingleOrDefault(x => x.Value.StopId == stopId); + if (currentStop.Value == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (currentStop.Value is not DeliveryStop) + { + throw new InvalidOperationException("Stop is not a delivery."); + } + + if (currentStop.Value.Status != StopStatus.Arrived) + { + Arrive(stopId, departed); + } + + currentStop.Value.Depart(departed); + _events.Add(new Delivered(stopId, departed)); + } + + public bool IsComplete() + { + return _stops.All(x => x.Value.Status == StopStatus.Departed); + } + } + + public class PickupStop : Stop + { + public PickupStop(int stopId) + { + StopId = stopId; + } + } + + public class DeliveryStop : Stop + { + public DeliveryStop(int stopId) + { + StopId = stopId; + } + } + + public record ShipmentStop(int StopId, StopType StopType, int Sequence); + + public abstract class Stop + { + public int StopId { get; protected set; } + public StopStatus Status { get; private set; } = StopStatus.InTransit; + public Address Address { get; protected set;} + public DateTime Scheduled { get; protected set;} + public DateTime Arrived { get; protected set; } + public DateTime? Departed { get; protected set; } + + public void Arrive(DateTime arrived) + { + if (Status != StopStatus.InTransit) + { + throw new InvalidOperationException("Stop has already arrived."); + } + + Status = StopStatus.Arrived; + Arrived = arrived; + } + + public void Depart(DateTime departed) + { + if (Status == StopStatus.Departed) + { + throw new InvalidOperationException("Stop has already departed."); + } + + if (departed < Arrived) + { + throw new InvalidOperationException("Departed Date/Time cannot be before Arrived Date/Time."); + } + + Status = StopStatus.Departed; + Departed = departed; + } + } + + public enum StopType + { + Pickup, + Delivery + } + + public enum StopStatus + { + InTransit, + Arrived, + Departed + } + + public record Address(string Street, string City, string Postal, string Country); + + public interface IBus + { + Task Publish(IEvent evnt); + } +} 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/Events.cs b/Events.cs new file mode 100644 index 0000000..161233b --- /dev/null +++ b/Events.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; + +namespace Demo.EventSourced +{ + public interface IEvent { } + + public record Dispatched(int ShipmentId, IEnumerable Stops, DateTime Start) : IEvent; + + public record Arrived(int StopId, DateTime Arrival) : IEvent; + + public record ArrivedLate(int StopId, TimeSpan Delay) : 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/ShipmentAggregateRoot.cs b/ShipmentAggregateRoot.cs new file mode 100644 index 0000000..898f6dc --- /dev/null +++ b/ShipmentAggregateRoot.cs @@ -0,0 +1,203 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Demo.EventSourced; + +namespace Demo +{ + public class ShipmentAggregateRoot + { + private readonly SortedList _stops = new(); + private readonly List _events = new(); + + private ShipmentAggregateRoot(IReadOnlyList stops) + { + for(var x = 0; x < stops.Count; x++) + { + _stops.Add(x, stops[x]); + } + } + + public static ShipmentAggregateRoot Factory(PickupStop pickup, DeliveryStop delivery) + { + return new ShipmentAggregateRoot(new Stop[] { pickup, delivery }); + } + + public static ShipmentAggregateRoot Factory(Stop[] stops) + { + if (stops.Length < 2) + { + throw new InvalidOperationException("Shipment requires at least 2 stops."); + } + + if (stops.First() is not PickupStop) + { + throw new InvalidOperationException("First stop must be a Pickup"); + } + + if (stops.Last() is not DeliveryStop) + { + throw new InvalidOperationException("first stop must be a Pickup"); + } + + return new ShipmentAggregateRoot(stops); + } + + public List GetUncommittedEvents() + { + var evnts = new List(_events); + _events.Clear(); + return evnts; + } + + public void Arrive(int stopId, DateTime arrived) + { + var currentStop = _stops.SingleOrDefault(x => x.Value.StopId == stopId); + if (currentStop.Value == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + var previousStopsAreNotDeparted = _stops.Any(x => x.Key < currentStop.Key && x.Value.Status != StopStatus.Departed); + if (previousStopsAreNotDeparted) + { + throw new InvalidOperationException("Previous stops have not departed."); + } + + currentStop.Value.Arrive(arrived); + + _events.Add(new Arrived(stopId, arrived)); + + var arrivedToScheduled = arrived - currentStop.Value.Scheduled; + if (arrivedToScheduled.TotalHours > 0) + { + _events.Add(new ArrivedLate(stopId, arrivedToScheduled)); + } + } + + public void Pickup(int stopId, DateTime departed) + { + var currentStop = _stops.SingleOrDefault(x => x.Value.StopId == stopId); + if (currentStop.Value == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (currentStop.Value is not PickupStop) + { + throw new InvalidOperationException("Stop is not a pickup."); + } + + if (currentStop.Value.Status != StopStatus.Arrived) + { + Arrive(stopId, departed); + } + + currentStop.Value.Depart(departed); + _events.Add(new PickedUp(stopId, departed)); + } + + public void Deliver(int stopId, DateTime departed) + { + var currentStop = _stops.SingleOrDefault(x => x.Value.StopId == stopId); + if (currentStop.Value == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (currentStop.Value is not DeliveryStop) + { + throw new InvalidOperationException("Stop is not a delivery."); + } + + if (currentStop.Value.Status != StopStatus.Arrived) + { + Arrive(stopId, departed); + } + + currentStop.Value.Depart(departed); + _events.Add(new Delivered(stopId, departed)); + } + + public bool IsComplete() + { + return _stops.All(x => x.Value.Status == StopStatus.Departed); + } + } + + public class PickupStop : Stop + { + public PickupStop(int stopId) + { + StopId = stopId; + } + } + + public class DeliveryStop : Stop + { + public DeliveryStop(int stopId) + { + StopId = stopId; + } + } + + public record ShipmentStop(int StopId, StopType StopType, int Sequence); + + public abstract class Stop + { + public int StopId { get; protected set; } + public StopStatus Status { get; private set; } = StopStatus.InTransit; + public Address Address { get; protected set;} + public DateTime Scheduled { get; protected set;} + public DateTime Arrived { get; protected set; } + public DateTime? Departed { get; protected set; } + + public void Arrive(DateTime arrived) + { + if (Status != StopStatus.InTransit) + { + throw new InvalidOperationException("Stop has already arrived."); + } + + Status = StopStatus.Arrived; + Arrived = arrived; + } + + public void Depart(DateTime departed) + { + if (Status == StopStatus.Departed) + { + throw new InvalidOperationException("Stop has already departed."); + } + + if (departed < Arrived) + { + throw new InvalidOperationException("Departed Date/Time cannot be before Arrived Date/Time."); + } + + Status = StopStatus.Departed; + Departed = departed; + } + } + + public enum StopType + { + Pickup, + Delivery + } + + public enum StopStatus + { + InTransit, + Arrived, + Departed + } + + public record Address(string Street, string City, string Postal, string Country); + + public interface IBus + { + Task Publish(IEvent evnt); + } +} diff --git a/Tests.cs b/Tests.cs new file mode 100644 index 0000000..b179028 --- /dev/null +++ b/Tests.cs @@ -0,0 +1,131 @@ +using System; +using System.Linq; +using Demo.EventSourced; +using Shouldly; +using Xunit; + +namespace Demo +{ + public class Tests + { + private readonly ShipmentAggregateRoot _shipmentAggregateRoot; + private readonly DateTime _arriveShipperDateTime; + private readonly DateTime _pickupShipperDateTime; + private readonly DateTime _arriveDestinationDateTime; + private readonly DateTime _deliveryDestinationDateTime; + + public Tests() + { + var pickup = new PickupStop(1); + var delivery = new DeliveryStop(2); + _shipmentAggregateRoot = ShipmentAggregateRoot.Factory(pickup, delivery); + + _arriveShipperDateTime = new DateTime(2021, 1, 23, 13, 30, 00); + _pickupShipperDateTime = new DateTime(2021, 1, 23, 13, 32, 00); + _arriveDestinationDateTime = new DateTime(2021, 1, 23, 14, 05, 00); + _deliveryDestinationDateTime = new DateTime(2021, 1, 23, 14, 07, 00); + } + + [Fact] + public void CompleteShipment() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + _shipmentAggregateRoot.Arrive(2, _arriveDestinationDateTime); + _shipmentAggregateRoot.Deliver(2, _deliveryDestinationDateTime); + _shipmentAggregateRoot.IsComplete().ShouldBeTrue(); + } + + [Fact] + public void CanPickupWithoutArriving() + { + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + var evnts = _shipmentAggregateRoot.GetUncommittedEvents(); + evnts.OfType().Count().ShouldBe(1); + } + + [Fact] + public void PickupWithoutArriving() + { + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + + var evnts = _shipmentAggregateRoot.GetUncommittedEvents(); + evnts.OfType().Count().ShouldBe(1); + evnts.OfType().Count().ShouldBe(1); + } + + [Fact] + public void CanDeliverWithoutArriving() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + _shipmentAggregateRoot.Deliver(2, _deliveryDestinationDateTime); + + var evnts = _shipmentAggregateRoot.GetUncommittedEvents(); + evnts.OfType().Count().ShouldBe(2); + evnts.OfType().Count().ShouldBe(1); + } + + [Fact] + public void CannotPickupAtDelivery() + { + Should.Throw(() => _shipmentAggregateRoot.Pickup(2, _pickupShipperDateTime), "Stop is not a delivery."); + } + + [Fact] + public void CannotDeliverAtPickup() + { + Should.Throw(() => _shipmentAggregateRoot.Deliver(1, _deliveryDestinationDateTime), "Stop is not a pickup."); + } + + [Fact] + public void ArriveStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRoot.Arrive(0, _arriveShipperDateTime), "Stop does not exist."); + } + + [Fact] + public void PickupStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRoot.Pickup(0, _pickupShipperDateTime), "Stop does not exist."); + } + + [Fact] + public void DeliverStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRoot.Deliver(0, _deliveryDestinationDateTime), "Stop does not exist."); + } + + [Fact] + public void ArriveNonDepartedStops() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + Should.Throw(() => _shipmentAggregateRoot.Arrive(2, _arriveDestinationDateTime), "Previous stops have not departed."); + } + + [Fact] + public void AlreadyArrived() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + Should.Throw(() => _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime), "Stop has already arrived."); + } + + [Fact] + public void AlreadyPickedUp() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + Should.Throw(() => _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime), "Stop has already departed."); + } + + [Fact] + public void AlreadyDelivered() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + _shipmentAggregateRoot.Arrive(2, _arriveDestinationDateTime); + _shipmentAggregateRoot.Deliver(2, _deliveryDestinationDateTime); + Should.Throw(() => _shipmentAggregateRoot.Deliver(2, _deliveryDestinationDateTime), "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/Events.cs b/Events.cs new file mode 100644 index 0000000..161233b --- /dev/null +++ b/Events.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; + +namespace Demo.EventSourced +{ + public interface IEvent { } + + public record Dispatched(int ShipmentId, IEnumerable Stops, DateTime Start) : IEvent; + + public record Arrived(int StopId, DateTime Arrival) : IEvent; + + public record ArrivedLate(int StopId, TimeSpan Delay) : 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/ShipmentAggregateRoot.cs b/ShipmentAggregateRoot.cs new file mode 100644 index 0000000..898f6dc --- /dev/null +++ b/ShipmentAggregateRoot.cs @@ -0,0 +1,203 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Demo.EventSourced; + +namespace Demo +{ + public class ShipmentAggregateRoot + { + private readonly SortedList _stops = new(); + private readonly List _events = new(); + + private ShipmentAggregateRoot(IReadOnlyList stops) + { + for(var x = 0; x < stops.Count; x++) + { + _stops.Add(x, stops[x]); + } + } + + public static ShipmentAggregateRoot Factory(PickupStop pickup, DeliveryStop delivery) + { + return new ShipmentAggregateRoot(new Stop[] { pickup, delivery }); + } + + public static ShipmentAggregateRoot Factory(Stop[] stops) + { + if (stops.Length < 2) + { + throw new InvalidOperationException("Shipment requires at least 2 stops."); + } + + if (stops.First() is not PickupStop) + { + throw new InvalidOperationException("First stop must be a Pickup"); + } + + if (stops.Last() is not DeliveryStop) + { + throw new InvalidOperationException("first stop must be a Pickup"); + } + + return new ShipmentAggregateRoot(stops); + } + + public List GetUncommittedEvents() + { + var evnts = new List(_events); + _events.Clear(); + return evnts; + } + + public void Arrive(int stopId, DateTime arrived) + { + var currentStop = _stops.SingleOrDefault(x => x.Value.StopId == stopId); + if (currentStop.Value == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + var previousStopsAreNotDeparted = _stops.Any(x => x.Key < currentStop.Key && x.Value.Status != StopStatus.Departed); + if (previousStopsAreNotDeparted) + { + throw new InvalidOperationException("Previous stops have not departed."); + } + + currentStop.Value.Arrive(arrived); + + _events.Add(new Arrived(stopId, arrived)); + + var arrivedToScheduled = arrived - currentStop.Value.Scheduled; + if (arrivedToScheduled.TotalHours > 0) + { + _events.Add(new ArrivedLate(stopId, arrivedToScheduled)); + } + } + + public void Pickup(int stopId, DateTime departed) + { + var currentStop = _stops.SingleOrDefault(x => x.Value.StopId == stopId); + if (currentStop.Value == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (currentStop.Value is not PickupStop) + { + throw new InvalidOperationException("Stop is not a pickup."); + } + + if (currentStop.Value.Status != StopStatus.Arrived) + { + Arrive(stopId, departed); + } + + currentStop.Value.Depart(departed); + _events.Add(new PickedUp(stopId, departed)); + } + + public void Deliver(int stopId, DateTime departed) + { + var currentStop = _stops.SingleOrDefault(x => x.Value.StopId == stopId); + if (currentStop.Value == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (currentStop.Value is not DeliveryStop) + { + throw new InvalidOperationException("Stop is not a delivery."); + } + + if (currentStop.Value.Status != StopStatus.Arrived) + { + Arrive(stopId, departed); + } + + currentStop.Value.Depart(departed); + _events.Add(new Delivered(stopId, departed)); + } + + public bool IsComplete() + { + return _stops.All(x => x.Value.Status == StopStatus.Departed); + } + } + + public class PickupStop : Stop + { + public PickupStop(int stopId) + { + StopId = stopId; + } + } + + public class DeliveryStop : Stop + { + public DeliveryStop(int stopId) + { + StopId = stopId; + } + } + + public record ShipmentStop(int StopId, StopType StopType, int Sequence); + + public abstract class Stop + { + public int StopId { get; protected set; } + public StopStatus Status { get; private set; } = StopStatus.InTransit; + public Address Address { get; protected set;} + public DateTime Scheduled { get; protected set;} + public DateTime Arrived { get; protected set; } + public DateTime? Departed { get; protected set; } + + public void Arrive(DateTime arrived) + { + if (Status != StopStatus.InTransit) + { + throw new InvalidOperationException("Stop has already arrived."); + } + + Status = StopStatus.Arrived; + Arrived = arrived; + } + + public void Depart(DateTime departed) + { + if (Status == StopStatus.Departed) + { + throw new InvalidOperationException("Stop has already departed."); + } + + if (departed < Arrived) + { + throw new InvalidOperationException("Departed Date/Time cannot be before Arrived Date/Time."); + } + + Status = StopStatus.Departed; + Departed = departed; + } + } + + public enum StopType + { + Pickup, + Delivery + } + + public enum StopStatus + { + InTransit, + Arrived, + Departed + } + + public record Address(string Street, string City, string Postal, string Country); + + public interface IBus + { + Task Publish(IEvent evnt); + } +} diff --git a/Tests.cs b/Tests.cs new file mode 100644 index 0000000..b179028 --- /dev/null +++ b/Tests.cs @@ -0,0 +1,131 @@ +using System; +using System.Linq; +using Demo.EventSourced; +using Shouldly; +using Xunit; + +namespace Demo +{ + public class Tests + { + private readonly ShipmentAggregateRoot _shipmentAggregateRoot; + private readonly DateTime _arriveShipperDateTime; + private readonly DateTime _pickupShipperDateTime; + private readonly DateTime _arriveDestinationDateTime; + private readonly DateTime _deliveryDestinationDateTime; + + public Tests() + { + var pickup = new PickupStop(1); + var delivery = new DeliveryStop(2); + _shipmentAggregateRoot = ShipmentAggregateRoot.Factory(pickup, delivery); + + _arriveShipperDateTime = new DateTime(2021, 1, 23, 13, 30, 00); + _pickupShipperDateTime = new DateTime(2021, 1, 23, 13, 32, 00); + _arriveDestinationDateTime = new DateTime(2021, 1, 23, 14, 05, 00); + _deliveryDestinationDateTime = new DateTime(2021, 1, 23, 14, 07, 00); + } + + [Fact] + public void CompleteShipment() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + _shipmentAggregateRoot.Arrive(2, _arriveDestinationDateTime); + _shipmentAggregateRoot.Deliver(2, _deliveryDestinationDateTime); + _shipmentAggregateRoot.IsComplete().ShouldBeTrue(); + } + + [Fact] + public void CanPickupWithoutArriving() + { + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + var evnts = _shipmentAggregateRoot.GetUncommittedEvents(); + evnts.OfType().Count().ShouldBe(1); + } + + [Fact] + public void PickupWithoutArriving() + { + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + + var evnts = _shipmentAggregateRoot.GetUncommittedEvents(); + evnts.OfType().Count().ShouldBe(1); + evnts.OfType().Count().ShouldBe(1); + } + + [Fact] + public void CanDeliverWithoutArriving() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + _shipmentAggregateRoot.Deliver(2, _deliveryDestinationDateTime); + + var evnts = _shipmentAggregateRoot.GetUncommittedEvents(); + evnts.OfType().Count().ShouldBe(2); + evnts.OfType().Count().ShouldBe(1); + } + + [Fact] + public void CannotPickupAtDelivery() + { + Should.Throw(() => _shipmentAggregateRoot.Pickup(2, _pickupShipperDateTime), "Stop is not a delivery."); + } + + [Fact] + public void CannotDeliverAtPickup() + { + Should.Throw(() => _shipmentAggregateRoot.Deliver(1, _deliveryDestinationDateTime), "Stop is not a pickup."); + } + + [Fact] + public void ArriveStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRoot.Arrive(0, _arriveShipperDateTime), "Stop does not exist."); + } + + [Fact] + public void PickupStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRoot.Pickup(0, _pickupShipperDateTime), "Stop does not exist."); + } + + [Fact] + public void DeliverStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRoot.Deliver(0, _deliveryDestinationDateTime), "Stop does not exist."); + } + + [Fact] + public void ArriveNonDepartedStops() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + Should.Throw(() => _shipmentAggregateRoot.Arrive(2, _arriveDestinationDateTime), "Previous stops have not departed."); + } + + [Fact] + public void AlreadyArrived() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + Should.Throw(() => _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime), "Stop has already arrived."); + } + + [Fact] + public void AlreadyPickedUp() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + Should.Throw(() => _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime), "Stop has already departed."); + } + + [Fact] + public void AlreadyDelivered() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + _shipmentAggregateRoot.Arrive(2, _arriveDestinationDateTime); + _shipmentAggregateRoot.Deliver(2, _deliveryDestinationDateTime); + Should.Throw(() => _shipmentAggregateRoot.Deliver(2, _deliveryDestinationDateTime), "Stop has already departed."); + } + } +} \ No newline at end of file diff --git a/TransactionScriptVsDomain.csproj b/TransactionScriptVsDomain.csproj new file mode 100644 index 0000000..75b0bd1 --- /dev/null +++ b/TransactionScriptVsDomain.csproj @@ -0,0 +1,15 @@ + + + + net6.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/Events.cs b/Events.cs new file mode 100644 index 0000000..161233b --- /dev/null +++ b/Events.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; + +namespace Demo.EventSourced +{ + public interface IEvent { } + + public record Dispatched(int ShipmentId, IEnumerable Stops, DateTime Start) : IEvent; + + public record Arrived(int StopId, DateTime Arrival) : IEvent; + + public record ArrivedLate(int StopId, TimeSpan Delay) : 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/ShipmentAggregateRoot.cs b/ShipmentAggregateRoot.cs new file mode 100644 index 0000000..898f6dc --- /dev/null +++ b/ShipmentAggregateRoot.cs @@ -0,0 +1,203 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Demo.EventSourced; + +namespace Demo +{ + public class ShipmentAggregateRoot + { + private readonly SortedList _stops = new(); + private readonly List _events = new(); + + private ShipmentAggregateRoot(IReadOnlyList stops) + { + for(var x = 0; x < stops.Count; x++) + { + _stops.Add(x, stops[x]); + } + } + + public static ShipmentAggregateRoot Factory(PickupStop pickup, DeliveryStop delivery) + { + return new ShipmentAggregateRoot(new Stop[] { pickup, delivery }); + } + + public static ShipmentAggregateRoot Factory(Stop[] stops) + { + if (stops.Length < 2) + { + throw new InvalidOperationException("Shipment requires at least 2 stops."); + } + + if (stops.First() is not PickupStop) + { + throw new InvalidOperationException("First stop must be a Pickup"); + } + + if (stops.Last() is not DeliveryStop) + { + throw new InvalidOperationException("first stop must be a Pickup"); + } + + return new ShipmentAggregateRoot(stops); + } + + public List GetUncommittedEvents() + { + var evnts = new List(_events); + _events.Clear(); + return evnts; + } + + public void Arrive(int stopId, DateTime arrived) + { + var currentStop = _stops.SingleOrDefault(x => x.Value.StopId == stopId); + if (currentStop.Value == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + var previousStopsAreNotDeparted = _stops.Any(x => x.Key < currentStop.Key && x.Value.Status != StopStatus.Departed); + if (previousStopsAreNotDeparted) + { + throw new InvalidOperationException("Previous stops have not departed."); + } + + currentStop.Value.Arrive(arrived); + + _events.Add(new Arrived(stopId, arrived)); + + var arrivedToScheduled = arrived - currentStop.Value.Scheduled; + if (arrivedToScheduled.TotalHours > 0) + { + _events.Add(new ArrivedLate(stopId, arrivedToScheduled)); + } + } + + public void Pickup(int stopId, DateTime departed) + { + var currentStop = _stops.SingleOrDefault(x => x.Value.StopId == stopId); + if (currentStop.Value == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (currentStop.Value is not PickupStop) + { + throw new InvalidOperationException("Stop is not a pickup."); + } + + if (currentStop.Value.Status != StopStatus.Arrived) + { + Arrive(stopId, departed); + } + + currentStop.Value.Depart(departed); + _events.Add(new PickedUp(stopId, departed)); + } + + public void Deliver(int stopId, DateTime departed) + { + var currentStop = _stops.SingleOrDefault(x => x.Value.StopId == stopId); + if (currentStop.Value == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (currentStop.Value is not DeliveryStop) + { + throw new InvalidOperationException("Stop is not a delivery."); + } + + if (currentStop.Value.Status != StopStatus.Arrived) + { + Arrive(stopId, departed); + } + + currentStop.Value.Depart(departed); + _events.Add(new Delivered(stopId, departed)); + } + + public bool IsComplete() + { + return _stops.All(x => x.Value.Status == StopStatus.Departed); + } + } + + public class PickupStop : Stop + { + public PickupStop(int stopId) + { + StopId = stopId; + } + } + + public class DeliveryStop : Stop + { + public DeliveryStop(int stopId) + { + StopId = stopId; + } + } + + public record ShipmentStop(int StopId, StopType StopType, int Sequence); + + public abstract class Stop + { + public int StopId { get; protected set; } + public StopStatus Status { get; private set; } = StopStatus.InTransit; + public Address Address { get; protected set;} + public DateTime Scheduled { get; protected set;} + public DateTime Arrived { get; protected set; } + public DateTime? Departed { get; protected set; } + + public void Arrive(DateTime arrived) + { + if (Status != StopStatus.InTransit) + { + throw new InvalidOperationException("Stop has already arrived."); + } + + Status = StopStatus.Arrived; + Arrived = arrived; + } + + public void Depart(DateTime departed) + { + if (Status == StopStatus.Departed) + { + throw new InvalidOperationException("Stop has already departed."); + } + + if (departed < Arrived) + { + throw new InvalidOperationException("Departed Date/Time cannot be before Arrived Date/Time."); + } + + Status = StopStatus.Departed; + Departed = departed; + } + } + + public enum StopType + { + Pickup, + Delivery + } + + public enum StopStatus + { + InTransit, + Arrived, + Departed + } + + public record Address(string Street, string City, string Postal, string Country); + + public interface IBus + { + Task Publish(IEvent evnt); + } +} diff --git a/Tests.cs b/Tests.cs new file mode 100644 index 0000000..b179028 --- /dev/null +++ b/Tests.cs @@ -0,0 +1,131 @@ +using System; +using System.Linq; +using Demo.EventSourced; +using Shouldly; +using Xunit; + +namespace Demo +{ + public class Tests + { + private readonly ShipmentAggregateRoot _shipmentAggregateRoot; + private readonly DateTime _arriveShipperDateTime; + private readonly DateTime _pickupShipperDateTime; + private readonly DateTime _arriveDestinationDateTime; + private readonly DateTime _deliveryDestinationDateTime; + + public Tests() + { + var pickup = new PickupStop(1); + var delivery = new DeliveryStop(2); + _shipmentAggregateRoot = ShipmentAggregateRoot.Factory(pickup, delivery); + + _arriveShipperDateTime = new DateTime(2021, 1, 23, 13, 30, 00); + _pickupShipperDateTime = new DateTime(2021, 1, 23, 13, 32, 00); + _arriveDestinationDateTime = new DateTime(2021, 1, 23, 14, 05, 00); + _deliveryDestinationDateTime = new DateTime(2021, 1, 23, 14, 07, 00); + } + + [Fact] + public void CompleteShipment() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + _shipmentAggregateRoot.Arrive(2, _arriveDestinationDateTime); + _shipmentAggregateRoot.Deliver(2, _deliveryDestinationDateTime); + _shipmentAggregateRoot.IsComplete().ShouldBeTrue(); + } + + [Fact] + public void CanPickupWithoutArriving() + { + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + var evnts = _shipmentAggregateRoot.GetUncommittedEvents(); + evnts.OfType().Count().ShouldBe(1); + } + + [Fact] + public void PickupWithoutArriving() + { + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + + var evnts = _shipmentAggregateRoot.GetUncommittedEvents(); + evnts.OfType().Count().ShouldBe(1); + evnts.OfType().Count().ShouldBe(1); + } + + [Fact] + public void CanDeliverWithoutArriving() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + _shipmentAggregateRoot.Deliver(2, _deliveryDestinationDateTime); + + var evnts = _shipmentAggregateRoot.GetUncommittedEvents(); + evnts.OfType().Count().ShouldBe(2); + evnts.OfType().Count().ShouldBe(1); + } + + [Fact] + public void CannotPickupAtDelivery() + { + Should.Throw(() => _shipmentAggregateRoot.Pickup(2, _pickupShipperDateTime), "Stop is not a delivery."); + } + + [Fact] + public void CannotDeliverAtPickup() + { + Should.Throw(() => _shipmentAggregateRoot.Deliver(1, _deliveryDestinationDateTime), "Stop is not a pickup."); + } + + [Fact] + public void ArriveStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRoot.Arrive(0, _arriveShipperDateTime), "Stop does not exist."); + } + + [Fact] + public void PickupStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRoot.Pickup(0, _pickupShipperDateTime), "Stop does not exist."); + } + + [Fact] + public void DeliverStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRoot.Deliver(0, _deliveryDestinationDateTime), "Stop does not exist."); + } + + [Fact] + public void ArriveNonDepartedStops() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + Should.Throw(() => _shipmentAggregateRoot.Arrive(2, _arriveDestinationDateTime), "Previous stops have not departed."); + } + + [Fact] + public void AlreadyArrived() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + Should.Throw(() => _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime), "Stop has already arrived."); + } + + [Fact] + public void AlreadyPickedUp() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + Should.Throw(() => _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime), "Stop has already departed."); + } + + [Fact] + public void AlreadyDelivered() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + _shipmentAggregateRoot.Arrive(2, _arriveDestinationDateTime); + _shipmentAggregateRoot.Deliver(2, _deliveryDestinationDateTime); + Should.Throw(() => _shipmentAggregateRoot.Deliver(2, _deliveryDestinationDateTime), "Stop has already departed."); + } + } +} \ No newline at end of file diff --git a/TransactionScriptVsDomain.csproj b/TransactionScriptVsDomain.csproj new file mode 100644 index 0000000..75b0bd1 --- /dev/null +++ b/TransactionScriptVsDomain.csproj @@ -0,0 +1,15 @@ + + + + net6.0 + + + + + + + + + + + diff --git a/TrxScript/Arrive.cs b/TrxScript/Arrive.cs new file mode 100644 index 0000000..a5b1235 --- /dev/null +++ b/TrxScript/Arrive.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Demo; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace TransactionScriptVsDomain.TrxScript +{ + public class Arrive : IRequest + { + public int StopId { get; set; } + public DateTime Arrived { get; set; } + } + + public class ArriveHandler : IRequestHandler + { + private readonly ShipmentDbContext _dbContext; + + public ArriveHandler(ShipmentDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task Handle(Arrive request, CancellationToken cancellationToken) + { + var stop = await _dbContext.Stops.SingleOrDefaultAsync(x => x.StopId == request.StopId); + if (stop == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (stop.Status != StopStatus.InTransit) + { + throw new InvalidOperationException("Stop has already arrived."); + } + + stop.Status = StopStatus.Arrived; + stop.Arrived = request.Arrived; + + await _dbContext.SaveChangesAsync(); + + return Unit.Value; + } + } +} \ 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/Events.cs b/Events.cs new file mode 100644 index 0000000..161233b --- /dev/null +++ b/Events.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; + +namespace Demo.EventSourced +{ + public interface IEvent { } + + public record Dispatched(int ShipmentId, IEnumerable Stops, DateTime Start) : IEvent; + + public record Arrived(int StopId, DateTime Arrival) : IEvent; + + public record ArrivedLate(int StopId, TimeSpan Delay) : 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/ShipmentAggregateRoot.cs b/ShipmentAggregateRoot.cs new file mode 100644 index 0000000..898f6dc --- /dev/null +++ b/ShipmentAggregateRoot.cs @@ -0,0 +1,203 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Demo.EventSourced; + +namespace Demo +{ + public class ShipmentAggregateRoot + { + private readonly SortedList _stops = new(); + private readonly List _events = new(); + + private ShipmentAggregateRoot(IReadOnlyList stops) + { + for(var x = 0; x < stops.Count; x++) + { + _stops.Add(x, stops[x]); + } + } + + public static ShipmentAggregateRoot Factory(PickupStop pickup, DeliveryStop delivery) + { + return new ShipmentAggregateRoot(new Stop[] { pickup, delivery }); + } + + public static ShipmentAggregateRoot Factory(Stop[] stops) + { + if (stops.Length < 2) + { + throw new InvalidOperationException("Shipment requires at least 2 stops."); + } + + if (stops.First() is not PickupStop) + { + throw new InvalidOperationException("First stop must be a Pickup"); + } + + if (stops.Last() is not DeliveryStop) + { + throw new InvalidOperationException("first stop must be a Pickup"); + } + + return new ShipmentAggregateRoot(stops); + } + + public List GetUncommittedEvents() + { + var evnts = new List(_events); + _events.Clear(); + return evnts; + } + + public void Arrive(int stopId, DateTime arrived) + { + var currentStop = _stops.SingleOrDefault(x => x.Value.StopId == stopId); + if (currentStop.Value == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + var previousStopsAreNotDeparted = _stops.Any(x => x.Key < currentStop.Key && x.Value.Status != StopStatus.Departed); + if (previousStopsAreNotDeparted) + { + throw new InvalidOperationException("Previous stops have not departed."); + } + + currentStop.Value.Arrive(arrived); + + _events.Add(new Arrived(stopId, arrived)); + + var arrivedToScheduled = arrived - currentStop.Value.Scheduled; + if (arrivedToScheduled.TotalHours > 0) + { + _events.Add(new ArrivedLate(stopId, arrivedToScheduled)); + } + } + + public void Pickup(int stopId, DateTime departed) + { + var currentStop = _stops.SingleOrDefault(x => x.Value.StopId == stopId); + if (currentStop.Value == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (currentStop.Value is not PickupStop) + { + throw new InvalidOperationException("Stop is not a pickup."); + } + + if (currentStop.Value.Status != StopStatus.Arrived) + { + Arrive(stopId, departed); + } + + currentStop.Value.Depart(departed); + _events.Add(new PickedUp(stopId, departed)); + } + + public void Deliver(int stopId, DateTime departed) + { + var currentStop = _stops.SingleOrDefault(x => x.Value.StopId == stopId); + if (currentStop.Value == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (currentStop.Value is not DeliveryStop) + { + throw new InvalidOperationException("Stop is not a delivery."); + } + + if (currentStop.Value.Status != StopStatus.Arrived) + { + Arrive(stopId, departed); + } + + currentStop.Value.Depart(departed); + _events.Add(new Delivered(stopId, departed)); + } + + public bool IsComplete() + { + return _stops.All(x => x.Value.Status == StopStatus.Departed); + } + } + + public class PickupStop : Stop + { + public PickupStop(int stopId) + { + StopId = stopId; + } + } + + public class DeliveryStop : Stop + { + public DeliveryStop(int stopId) + { + StopId = stopId; + } + } + + public record ShipmentStop(int StopId, StopType StopType, int Sequence); + + public abstract class Stop + { + public int StopId { get; protected set; } + public StopStatus Status { get; private set; } = StopStatus.InTransit; + public Address Address { get; protected set;} + public DateTime Scheduled { get; protected set;} + public DateTime Arrived { get; protected set; } + public DateTime? Departed { get; protected set; } + + public void Arrive(DateTime arrived) + { + if (Status != StopStatus.InTransit) + { + throw new InvalidOperationException("Stop has already arrived."); + } + + Status = StopStatus.Arrived; + Arrived = arrived; + } + + public void Depart(DateTime departed) + { + if (Status == StopStatus.Departed) + { + throw new InvalidOperationException("Stop has already departed."); + } + + if (departed < Arrived) + { + throw new InvalidOperationException("Departed Date/Time cannot be before Arrived Date/Time."); + } + + Status = StopStatus.Departed; + Departed = departed; + } + } + + public enum StopType + { + Pickup, + Delivery + } + + public enum StopStatus + { + InTransit, + Arrived, + Departed + } + + public record Address(string Street, string City, string Postal, string Country); + + public interface IBus + { + Task Publish(IEvent evnt); + } +} diff --git a/Tests.cs b/Tests.cs new file mode 100644 index 0000000..b179028 --- /dev/null +++ b/Tests.cs @@ -0,0 +1,131 @@ +using System; +using System.Linq; +using Demo.EventSourced; +using Shouldly; +using Xunit; + +namespace Demo +{ + public class Tests + { + private readonly ShipmentAggregateRoot _shipmentAggregateRoot; + private readonly DateTime _arriveShipperDateTime; + private readonly DateTime _pickupShipperDateTime; + private readonly DateTime _arriveDestinationDateTime; + private readonly DateTime _deliveryDestinationDateTime; + + public Tests() + { + var pickup = new PickupStop(1); + var delivery = new DeliveryStop(2); + _shipmentAggregateRoot = ShipmentAggregateRoot.Factory(pickup, delivery); + + _arriveShipperDateTime = new DateTime(2021, 1, 23, 13, 30, 00); + _pickupShipperDateTime = new DateTime(2021, 1, 23, 13, 32, 00); + _arriveDestinationDateTime = new DateTime(2021, 1, 23, 14, 05, 00); + _deliveryDestinationDateTime = new DateTime(2021, 1, 23, 14, 07, 00); + } + + [Fact] + public void CompleteShipment() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + _shipmentAggregateRoot.Arrive(2, _arriveDestinationDateTime); + _shipmentAggregateRoot.Deliver(2, _deliveryDestinationDateTime); + _shipmentAggregateRoot.IsComplete().ShouldBeTrue(); + } + + [Fact] + public void CanPickupWithoutArriving() + { + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + var evnts = _shipmentAggregateRoot.GetUncommittedEvents(); + evnts.OfType().Count().ShouldBe(1); + } + + [Fact] + public void PickupWithoutArriving() + { + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + + var evnts = _shipmentAggregateRoot.GetUncommittedEvents(); + evnts.OfType().Count().ShouldBe(1); + evnts.OfType().Count().ShouldBe(1); + } + + [Fact] + public void CanDeliverWithoutArriving() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + _shipmentAggregateRoot.Deliver(2, _deliveryDestinationDateTime); + + var evnts = _shipmentAggregateRoot.GetUncommittedEvents(); + evnts.OfType().Count().ShouldBe(2); + evnts.OfType().Count().ShouldBe(1); + } + + [Fact] + public void CannotPickupAtDelivery() + { + Should.Throw(() => _shipmentAggregateRoot.Pickup(2, _pickupShipperDateTime), "Stop is not a delivery."); + } + + [Fact] + public void CannotDeliverAtPickup() + { + Should.Throw(() => _shipmentAggregateRoot.Deliver(1, _deliveryDestinationDateTime), "Stop is not a pickup."); + } + + [Fact] + public void ArriveStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRoot.Arrive(0, _arriveShipperDateTime), "Stop does not exist."); + } + + [Fact] + public void PickupStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRoot.Pickup(0, _pickupShipperDateTime), "Stop does not exist."); + } + + [Fact] + public void DeliverStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRoot.Deliver(0, _deliveryDestinationDateTime), "Stop does not exist."); + } + + [Fact] + public void ArriveNonDepartedStops() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + Should.Throw(() => _shipmentAggregateRoot.Arrive(2, _arriveDestinationDateTime), "Previous stops have not departed."); + } + + [Fact] + public void AlreadyArrived() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + Should.Throw(() => _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime), "Stop has already arrived."); + } + + [Fact] + public void AlreadyPickedUp() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + Should.Throw(() => _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime), "Stop has already departed."); + } + + [Fact] + public void AlreadyDelivered() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + _shipmentAggregateRoot.Arrive(2, _arriveDestinationDateTime); + _shipmentAggregateRoot.Deliver(2, _deliveryDestinationDateTime); + Should.Throw(() => _shipmentAggregateRoot.Deliver(2, _deliveryDestinationDateTime), "Stop has already departed."); + } + } +} \ No newline at end of file diff --git a/TransactionScriptVsDomain.csproj b/TransactionScriptVsDomain.csproj new file mode 100644 index 0000000..75b0bd1 --- /dev/null +++ b/TransactionScriptVsDomain.csproj @@ -0,0 +1,15 @@ + + + + net6.0 + + + + + + + + + + + diff --git a/TrxScript/Arrive.cs b/TrxScript/Arrive.cs new file mode 100644 index 0000000..a5b1235 --- /dev/null +++ b/TrxScript/Arrive.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Demo; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace TransactionScriptVsDomain.TrxScript +{ + public class Arrive : IRequest + { + public int StopId { get; set; } + public DateTime Arrived { get; set; } + } + + public class ArriveHandler : IRequestHandler + { + private readonly ShipmentDbContext _dbContext; + + public ArriveHandler(ShipmentDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task Handle(Arrive request, CancellationToken cancellationToken) + { + var stop = await _dbContext.Stops.SingleOrDefaultAsync(x => x.StopId == request.StopId); + if (stop == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (stop.Status != StopStatus.InTransit) + { + throw new InvalidOperationException("Stop has already arrived."); + } + + stop.Status = StopStatus.Arrived; + stop.Arrived = request.Arrived; + + await _dbContext.SaveChangesAsync(); + + return Unit.Value; + } + } +} \ No newline at end of file diff --git a/TrxScript/Arrive2.cs b/TrxScript/Arrive2.cs new file mode 100644 index 0000000..ba52aa6 --- /dev/null +++ b/TrxScript/Arrive2.cs @@ -0,0 +1,59 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Demo; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace TransactionScriptVsDomain.TrxScript.Arrive2 +{ + public class Arrive : IRequest + { + public int ShipmentId { get; set; } + public int StopId { get; set; } + public DateTime Arrived { get; set; } + } + + public class ArriveHandler : IRequestHandler + { + private readonly ShipmentDbContext _dbContext; + + public ArriveHandler(ShipmentDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task Handle(Arrive request, CancellationToken cancellationToken) + { + var allStops = await _dbContext.Stops.Where(x => x.ShipmentId == request.ShipmentId).ToArrayAsync(); + + var stop = allStops.SingleOrDefault(x => x.StopId == request.StopId); + if (stop == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (stop.Status != StopStatus.InTransit) + { + throw new InvalidOperationException("Stop has already arrived."); + } + + var previousStopsAreNotDeparted = allStops + .Where(x => x.Scheduled < stop.Scheduled) + .Any(x => x.Status != StopStatus.Departed); + + if (previousStopsAreNotDeparted) + { + throw new InvalidOperationException("Previous stops have not departed."); + } + + stop.Status = StopStatus.Arrived; + stop.Arrived = request.Arrived; + + await _dbContext.SaveChangesAsync(); + + return Unit.Value; + } + } +} \ 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/Events.cs b/Events.cs new file mode 100644 index 0000000..161233b --- /dev/null +++ b/Events.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; + +namespace Demo.EventSourced +{ + public interface IEvent { } + + public record Dispatched(int ShipmentId, IEnumerable Stops, DateTime Start) : IEvent; + + public record Arrived(int StopId, DateTime Arrival) : IEvent; + + public record ArrivedLate(int StopId, TimeSpan Delay) : 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/ShipmentAggregateRoot.cs b/ShipmentAggregateRoot.cs new file mode 100644 index 0000000..898f6dc --- /dev/null +++ b/ShipmentAggregateRoot.cs @@ -0,0 +1,203 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Demo.EventSourced; + +namespace Demo +{ + public class ShipmentAggregateRoot + { + private readonly SortedList _stops = new(); + private readonly List _events = new(); + + private ShipmentAggregateRoot(IReadOnlyList stops) + { + for(var x = 0; x < stops.Count; x++) + { + _stops.Add(x, stops[x]); + } + } + + public static ShipmentAggregateRoot Factory(PickupStop pickup, DeliveryStop delivery) + { + return new ShipmentAggregateRoot(new Stop[] { pickup, delivery }); + } + + public static ShipmentAggregateRoot Factory(Stop[] stops) + { + if (stops.Length < 2) + { + throw new InvalidOperationException("Shipment requires at least 2 stops."); + } + + if (stops.First() is not PickupStop) + { + throw new InvalidOperationException("First stop must be a Pickup"); + } + + if (stops.Last() is not DeliveryStop) + { + throw new InvalidOperationException("first stop must be a Pickup"); + } + + return new ShipmentAggregateRoot(stops); + } + + public List GetUncommittedEvents() + { + var evnts = new List(_events); + _events.Clear(); + return evnts; + } + + public void Arrive(int stopId, DateTime arrived) + { + var currentStop = _stops.SingleOrDefault(x => x.Value.StopId == stopId); + if (currentStop.Value == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + var previousStopsAreNotDeparted = _stops.Any(x => x.Key < currentStop.Key && x.Value.Status != StopStatus.Departed); + if (previousStopsAreNotDeparted) + { + throw new InvalidOperationException("Previous stops have not departed."); + } + + currentStop.Value.Arrive(arrived); + + _events.Add(new Arrived(stopId, arrived)); + + var arrivedToScheduled = arrived - currentStop.Value.Scheduled; + if (arrivedToScheduled.TotalHours > 0) + { + _events.Add(new ArrivedLate(stopId, arrivedToScheduled)); + } + } + + public void Pickup(int stopId, DateTime departed) + { + var currentStop = _stops.SingleOrDefault(x => x.Value.StopId == stopId); + if (currentStop.Value == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (currentStop.Value is not PickupStop) + { + throw new InvalidOperationException("Stop is not a pickup."); + } + + if (currentStop.Value.Status != StopStatus.Arrived) + { + Arrive(stopId, departed); + } + + currentStop.Value.Depart(departed); + _events.Add(new PickedUp(stopId, departed)); + } + + public void Deliver(int stopId, DateTime departed) + { + var currentStop = _stops.SingleOrDefault(x => x.Value.StopId == stopId); + if (currentStop.Value == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (currentStop.Value is not DeliveryStop) + { + throw new InvalidOperationException("Stop is not a delivery."); + } + + if (currentStop.Value.Status != StopStatus.Arrived) + { + Arrive(stopId, departed); + } + + currentStop.Value.Depart(departed); + _events.Add(new Delivered(stopId, departed)); + } + + public bool IsComplete() + { + return _stops.All(x => x.Value.Status == StopStatus.Departed); + } + } + + public class PickupStop : Stop + { + public PickupStop(int stopId) + { + StopId = stopId; + } + } + + public class DeliveryStop : Stop + { + public DeliveryStop(int stopId) + { + StopId = stopId; + } + } + + public record ShipmentStop(int StopId, StopType StopType, int Sequence); + + public abstract class Stop + { + public int StopId { get; protected set; } + public StopStatus Status { get; private set; } = StopStatus.InTransit; + public Address Address { get; protected set;} + public DateTime Scheduled { get; protected set;} + public DateTime Arrived { get; protected set; } + public DateTime? Departed { get; protected set; } + + public void Arrive(DateTime arrived) + { + if (Status != StopStatus.InTransit) + { + throw new InvalidOperationException("Stop has already arrived."); + } + + Status = StopStatus.Arrived; + Arrived = arrived; + } + + public void Depart(DateTime departed) + { + if (Status == StopStatus.Departed) + { + throw new InvalidOperationException("Stop has already departed."); + } + + if (departed < Arrived) + { + throw new InvalidOperationException("Departed Date/Time cannot be before Arrived Date/Time."); + } + + Status = StopStatus.Departed; + Departed = departed; + } + } + + public enum StopType + { + Pickup, + Delivery + } + + public enum StopStatus + { + InTransit, + Arrived, + Departed + } + + public record Address(string Street, string City, string Postal, string Country); + + public interface IBus + { + Task Publish(IEvent evnt); + } +} diff --git a/Tests.cs b/Tests.cs new file mode 100644 index 0000000..b179028 --- /dev/null +++ b/Tests.cs @@ -0,0 +1,131 @@ +using System; +using System.Linq; +using Demo.EventSourced; +using Shouldly; +using Xunit; + +namespace Demo +{ + public class Tests + { + private readonly ShipmentAggregateRoot _shipmentAggregateRoot; + private readonly DateTime _arriveShipperDateTime; + private readonly DateTime _pickupShipperDateTime; + private readonly DateTime _arriveDestinationDateTime; + private readonly DateTime _deliveryDestinationDateTime; + + public Tests() + { + var pickup = new PickupStop(1); + var delivery = new DeliveryStop(2); + _shipmentAggregateRoot = ShipmentAggregateRoot.Factory(pickup, delivery); + + _arriveShipperDateTime = new DateTime(2021, 1, 23, 13, 30, 00); + _pickupShipperDateTime = new DateTime(2021, 1, 23, 13, 32, 00); + _arriveDestinationDateTime = new DateTime(2021, 1, 23, 14, 05, 00); + _deliveryDestinationDateTime = new DateTime(2021, 1, 23, 14, 07, 00); + } + + [Fact] + public void CompleteShipment() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + _shipmentAggregateRoot.Arrive(2, _arriveDestinationDateTime); + _shipmentAggregateRoot.Deliver(2, _deliveryDestinationDateTime); + _shipmentAggregateRoot.IsComplete().ShouldBeTrue(); + } + + [Fact] + public void CanPickupWithoutArriving() + { + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + var evnts = _shipmentAggregateRoot.GetUncommittedEvents(); + evnts.OfType().Count().ShouldBe(1); + } + + [Fact] + public void PickupWithoutArriving() + { + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + + var evnts = _shipmentAggregateRoot.GetUncommittedEvents(); + evnts.OfType().Count().ShouldBe(1); + evnts.OfType().Count().ShouldBe(1); + } + + [Fact] + public void CanDeliverWithoutArriving() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + _shipmentAggregateRoot.Deliver(2, _deliveryDestinationDateTime); + + var evnts = _shipmentAggregateRoot.GetUncommittedEvents(); + evnts.OfType().Count().ShouldBe(2); + evnts.OfType().Count().ShouldBe(1); + } + + [Fact] + public void CannotPickupAtDelivery() + { + Should.Throw(() => _shipmentAggregateRoot.Pickup(2, _pickupShipperDateTime), "Stop is not a delivery."); + } + + [Fact] + public void CannotDeliverAtPickup() + { + Should.Throw(() => _shipmentAggregateRoot.Deliver(1, _deliveryDestinationDateTime), "Stop is not a pickup."); + } + + [Fact] + public void ArriveStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRoot.Arrive(0, _arriveShipperDateTime), "Stop does not exist."); + } + + [Fact] + public void PickupStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRoot.Pickup(0, _pickupShipperDateTime), "Stop does not exist."); + } + + [Fact] + public void DeliverStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRoot.Deliver(0, _deliveryDestinationDateTime), "Stop does not exist."); + } + + [Fact] + public void ArriveNonDepartedStops() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + Should.Throw(() => _shipmentAggregateRoot.Arrive(2, _arriveDestinationDateTime), "Previous stops have not departed."); + } + + [Fact] + public void AlreadyArrived() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + Should.Throw(() => _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime), "Stop has already arrived."); + } + + [Fact] + public void AlreadyPickedUp() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + Should.Throw(() => _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime), "Stop has already departed."); + } + + [Fact] + public void AlreadyDelivered() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + _shipmentAggregateRoot.Arrive(2, _arriveDestinationDateTime); + _shipmentAggregateRoot.Deliver(2, _deliveryDestinationDateTime); + Should.Throw(() => _shipmentAggregateRoot.Deliver(2, _deliveryDestinationDateTime), "Stop has already departed."); + } + } +} \ No newline at end of file diff --git a/TransactionScriptVsDomain.csproj b/TransactionScriptVsDomain.csproj new file mode 100644 index 0000000..75b0bd1 --- /dev/null +++ b/TransactionScriptVsDomain.csproj @@ -0,0 +1,15 @@ + + + + net6.0 + + + + + + + + + + + diff --git a/TrxScript/Arrive.cs b/TrxScript/Arrive.cs new file mode 100644 index 0000000..a5b1235 --- /dev/null +++ b/TrxScript/Arrive.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Demo; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace TransactionScriptVsDomain.TrxScript +{ + public class Arrive : IRequest + { + public int StopId { get; set; } + public DateTime Arrived { get; set; } + } + + public class ArriveHandler : IRequestHandler + { + private readonly ShipmentDbContext _dbContext; + + public ArriveHandler(ShipmentDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task Handle(Arrive request, CancellationToken cancellationToken) + { + var stop = await _dbContext.Stops.SingleOrDefaultAsync(x => x.StopId == request.StopId); + if (stop == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (stop.Status != StopStatus.InTransit) + { + throw new InvalidOperationException("Stop has already arrived."); + } + + stop.Status = StopStatus.Arrived; + stop.Arrived = request.Arrived; + + await _dbContext.SaveChangesAsync(); + + return Unit.Value; + } + } +} \ No newline at end of file diff --git a/TrxScript/Arrive2.cs b/TrxScript/Arrive2.cs new file mode 100644 index 0000000..ba52aa6 --- /dev/null +++ b/TrxScript/Arrive2.cs @@ -0,0 +1,59 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Demo; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace TransactionScriptVsDomain.TrxScript.Arrive2 +{ + public class Arrive : IRequest + { + public int ShipmentId { get; set; } + public int StopId { get; set; } + public DateTime Arrived { get; set; } + } + + public class ArriveHandler : IRequestHandler + { + private readonly ShipmentDbContext _dbContext; + + public ArriveHandler(ShipmentDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task Handle(Arrive request, CancellationToken cancellationToken) + { + var allStops = await _dbContext.Stops.Where(x => x.ShipmentId == request.ShipmentId).ToArrayAsync(); + + var stop = allStops.SingleOrDefault(x => x.StopId == request.StopId); + if (stop == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (stop.Status != StopStatus.InTransit) + { + throw new InvalidOperationException("Stop has already arrived."); + } + + var previousStopsAreNotDeparted = allStops + .Where(x => x.Scheduled < stop.Scheduled) + .Any(x => x.Status != StopStatus.Departed); + + if (previousStopsAreNotDeparted) + { + throw new InvalidOperationException("Previous stops have not departed."); + } + + stop.Status = StopStatus.Arrived; + stop.Arrived = request.Arrived; + + await _dbContext.SaveChangesAsync(); + + return Unit.Value; + } + } +} \ No newline at end of file diff --git a/TrxScript/Arrive3.cs b/TrxScript/Arrive3.cs new file mode 100644 index 0000000..a34647f --- /dev/null +++ b/TrxScript/Arrive3.cs @@ -0,0 +1,64 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Demo; +using Demo.EventSourced; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace TransactionScriptVsDomain.TrxScript.Arrive3 +{ + public class Arrive : IRequest + { + public int ShipmentId { get; set; } + public int StopId { get; set; } + public DateTime Arrived { get; set; } + } + + public class ArriveHandler : IRequestHandler + { + private readonly ShipmentDbContext _dbContext; + private readonly IBus _bus; + + public ArriveHandler(ShipmentDbContext dbContext, IBus bus) + { + _dbContext = dbContext; + _bus = bus; + } + + public async Task Handle(Arrive request, CancellationToken cancellationToken) + { + var allStops = await _dbContext.Stops.Where(x => x.ShipmentId == request.ShipmentId).ToArrayAsync(); + + var stop = allStops.SingleOrDefault(x => x.StopId == request.StopId); + if (stop == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (stop.Status != StopStatus.InTransit) + { + throw new InvalidOperationException("Stop has already arrived."); + } + + var previousStopsAreNotDeparted = allStops + .Where(x => x.Scheduled < stop.Scheduled) + .Any(x => x.Status != StopStatus.Departed); + + if (previousStopsAreNotDeparted) + { + throw new InvalidOperationException("Previous stops have not departed."); + } + + stop.Status = StopStatus.Arrived; + stop.Arrived = request.Arrived; + + await _dbContext.SaveChangesAsync(); + + await _bus.Publish(new Arrived(stop.StopId, stop.Arrived)); + + return Unit.Value; + } + } +} \ 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/Events.cs b/Events.cs new file mode 100644 index 0000000..161233b --- /dev/null +++ b/Events.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; + +namespace Demo.EventSourced +{ + public interface IEvent { } + + public record Dispatched(int ShipmentId, IEnumerable Stops, DateTime Start) : IEvent; + + public record Arrived(int StopId, DateTime Arrival) : IEvent; + + public record ArrivedLate(int StopId, TimeSpan Delay) : 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/ShipmentAggregateRoot.cs b/ShipmentAggregateRoot.cs new file mode 100644 index 0000000..898f6dc --- /dev/null +++ b/ShipmentAggregateRoot.cs @@ -0,0 +1,203 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Demo.EventSourced; + +namespace Demo +{ + public class ShipmentAggregateRoot + { + private readonly SortedList _stops = new(); + private readonly List _events = new(); + + private ShipmentAggregateRoot(IReadOnlyList stops) + { + for(var x = 0; x < stops.Count; x++) + { + _stops.Add(x, stops[x]); + } + } + + public static ShipmentAggregateRoot Factory(PickupStop pickup, DeliveryStop delivery) + { + return new ShipmentAggregateRoot(new Stop[] { pickup, delivery }); + } + + public static ShipmentAggregateRoot Factory(Stop[] stops) + { + if (stops.Length < 2) + { + throw new InvalidOperationException("Shipment requires at least 2 stops."); + } + + if (stops.First() is not PickupStop) + { + throw new InvalidOperationException("First stop must be a Pickup"); + } + + if (stops.Last() is not DeliveryStop) + { + throw new InvalidOperationException("first stop must be a Pickup"); + } + + return new ShipmentAggregateRoot(stops); + } + + public List GetUncommittedEvents() + { + var evnts = new List(_events); + _events.Clear(); + return evnts; + } + + public void Arrive(int stopId, DateTime arrived) + { + var currentStop = _stops.SingleOrDefault(x => x.Value.StopId == stopId); + if (currentStop.Value == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + var previousStopsAreNotDeparted = _stops.Any(x => x.Key < currentStop.Key && x.Value.Status != StopStatus.Departed); + if (previousStopsAreNotDeparted) + { + throw new InvalidOperationException("Previous stops have not departed."); + } + + currentStop.Value.Arrive(arrived); + + _events.Add(new Arrived(stopId, arrived)); + + var arrivedToScheduled = arrived - currentStop.Value.Scheduled; + if (arrivedToScheduled.TotalHours > 0) + { + _events.Add(new ArrivedLate(stopId, arrivedToScheduled)); + } + } + + public void Pickup(int stopId, DateTime departed) + { + var currentStop = _stops.SingleOrDefault(x => x.Value.StopId == stopId); + if (currentStop.Value == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (currentStop.Value is not PickupStop) + { + throw new InvalidOperationException("Stop is not a pickup."); + } + + if (currentStop.Value.Status != StopStatus.Arrived) + { + Arrive(stopId, departed); + } + + currentStop.Value.Depart(departed); + _events.Add(new PickedUp(stopId, departed)); + } + + public void Deliver(int stopId, DateTime departed) + { + var currentStop = _stops.SingleOrDefault(x => x.Value.StopId == stopId); + if (currentStop.Value == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (currentStop.Value is not DeliveryStop) + { + throw new InvalidOperationException("Stop is not a delivery."); + } + + if (currentStop.Value.Status != StopStatus.Arrived) + { + Arrive(stopId, departed); + } + + currentStop.Value.Depart(departed); + _events.Add(new Delivered(stopId, departed)); + } + + public bool IsComplete() + { + return _stops.All(x => x.Value.Status == StopStatus.Departed); + } + } + + public class PickupStop : Stop + { + public PickupStop(int stopId) + { + StopId = stopId; + } + } + + public class DeliveryStop : Stop + { + public DeliveryStop(int stopId) + { + StopId = stopId; + } + } + + public record ShipmentStop(int StopId, StopType StopType, int Sequence); + + public abstract class Stop + { + public int StopId { get; protected set; } + public StopStatus Status { get; private set; } = StopStatus.InTransit; + public Address Address { get; protected set;} + public DateTime Scheduled { get; protected set;} + public DateTime Arrived { get; protected set; } + public DateTime? Departed { get; protected set; } + + public void Arrive(DateTime arrived) + { + if (Status != StopStatus.InTransit) + { + throw new InvalidOperationException("Stop has already arrived."); + } + + Status = StopStatus.Arrived; + Arrived = arrived; + } + + public void Depart(DateTime departed) + { + if (Status == StopStatus.Departed) + { + throw new InvalidOperationException("Stop has already departed."); + } + + if (departed < Arrived) + { + throw new InvalidOperationException("Departed Date/Time cannot be before Arrived Date/Time."); + } + + Status = StopStatus.Departed; + Departed = departed; + } + } + + public enum StopType + { + Pickup, + Delivery + } + + public enum StopStatus + { + InTransit, + Arrived, + Departed + } + + public record Address(string Street, string City, string Postal, string Country); + + public interface IBus + { + Task Publish(IEvent evnt); + } +} diff --git a/Tests.cs b/Tests.cs new file mode 100644 index 0000000..b179028 --- /dev/null +++ b/Tests.cs @@ -0,0 +1,131 @@ +using System; +using System.Linq; +using Demo.EventSourced; +using Shouldly; +using Xunit; + +namespace Demo +{ + public class Tests + { + private readonly ShipmentAggregateRoot _shipmentAggregateRoot; + private readonly DateTime _arriveShipperDateTime; + private readonly DateTime _pickupShipperDateTime; + private readonly DateTime _arriveDestinationDateTime; + private readonly DateTime _deliveryDestinationDateTime; + + public Tests() + { + var pickup = new PickupStop(1); + var delivery = new DeliveryStop(2); + _shipmentAggregateRoot = ShipmentAggregateRoot.Factory(pickup, delivery); + + _arriveShipperDateTime = new DateTime(2021, 1, 23, 13, 30, 00); + _pickupShipperDateTime = new DateTime(2021, 1, 23, 13, 32, 00); + _arriveDestinationDateTime = new DateTime(2021, 1, 23, 14, 05, 00); + _deliveryDestinationDateTime = new DateTime(2021, 1, 23, 14, 07, 00); + } + + [Fact] + public void CompleteShipment() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + _shipmentAggregateRoot.Arrive(2, _arriveDestinationDateTime); + _shipmentAggregateRoot.Deliver(2, _deliveryDestinationDateTime); + _shipmentAggregateRoot.IsComplete().ShouldBeTrue(); + } + + [Fact] + public void CanPickupWithoutArriving() + { + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + var evnts = _shipmentAggregateRoot.GetUncommittedEvents(); + evnts.OfType().Count().ShouldBe(1); + } + + [Fact] + public void PickupWithoutArriving() + { + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + + var evnts = _shipmentAggregateRoot.GetUncommittedEvents(); + evnts.OfType().Count().ShouldBe(1); + evnts.OfType().Count().ShouldBe(1); + } + + [Fact] + public void CanDeliverWithoutArriving() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + _shipmentAggregateRoot.Deliver(2, _deliveryDestinationDateTime); + + var evnts = _shipmentAggregateRoot.GetUncommittedEvents(); + evnts.OfType().Count().ShouldBe(2); + evnts.OfType().Count().ShouldBe(1); + } + + [Fact] + public void CannotPickupAtDelivery() + { + Should.Throw(() => _shipmentAggregateRoot.Pickup(2, _pickupShipperDateTime), "Stop is not a delivery."); + } + + [Fact] + public void CannotDeliverAtPickup() + { + Should.Throw(() => _shipmentAggregateRoot.Deliver(1, _deliveryDestinationDateTime), "Stop is not a pickup."); + } + + [Fact] + public void ArriveStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRoot.Arrive(0, _arriveShipperDateTime), "Stop does not exist."); + } + + [Fact] + public void PickupStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRoot.Pickup(0, _pickupShipperDateTime), "Stop does not exist."); + } + + [Fact] + public void DeliverStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRoot.Deliver(0, _deliveryDestinationDateTime), "Stop does not exist."); + } + + [Fact] + public void ArriveNonDepartedStops() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + Should.Throw(() => _shipmentAggregateRoot.Arrive(2, _arriveDestinationDateTime), "Previous stops have not departed."); + } + + [Fact] + public void AlreadyArrived() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + Should.Throw(() => _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime), "Stop has already arrived."); + } + + [Fact] + public void AlreadyPickedUp() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + Should.Throw(() => _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime), "Stop has already departed."); + } + + [Fact] + public void AlreadyDelivered() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + _shipmentAggregateRoot.Arrive(2, _arriveDestinationDateTime); + _shipmentAggregateRoot.Deliver(2, _deliveryDestinationDateTime); + Should.Throw(() => _shipmentAggregateRoot.Deliver(2, _deliveryDestinationDateTime), "Stop has already departed."); + } + } +} \ No newline at end of file diff --git a/TransactionScriptVsDomain.csproj b/TransactionScriptVsDomain.csproj new file mode 100644 index 0000000..75b0bd1 --- /dev/null +++ b/TransactionScriptVsDomain.csproj @@ -0,0 +1,15 @@ + + + + net6.0 + + + + + + + + + + + diff --git a/TrxScript/Arrive.cs b/TrxScript/Arrive.cs new file mode 100644 index 0000000..a5b1235 --- /dev/null +++ b/TrxScript/Arrive.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Demo; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace TransactionScriptVsDomain.TrxScript +{ + public class Arrive : IRequest + { + public int StopId { get; set; } + public DateTime Arrived { get; set; } + } + + public class ArriveHandler : IRequestHandler + { + private readonly ShipmentDbContext _dbContext; + + public ArriveHandler(ShipmentDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task Handle(Arrive request, CancellationToken cancellationToken) + { + var stop = await _dbContext.Stops.SingleOrDefaultAsync(x => x.StopId == request.StopId); + if (stop == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (stop.Status != StopStatus.InTransit) + { + throw new InvalidOperationException("Stop has already arrived."); + } + + stop.Status = StopStatus.Arrived; + stop.Arrived = request.Arrived; + + await _dbContext.SaveChangesAsync(); + + return Unit.Value; + } + } +} \ No newline at end of file diff --git a/TrxScript/Arrive2.cs b/TrxScript/Arrive2.cs new file mode 100644 index 0000000..ba52aa6 --- /dev/null +++ b/TrxScript/Arrive2.cs @@ -0,0 +1,59 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Demo; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace TransactionScriptVsDomain.TrxScript.Arrive2 +{ + public class Arrive : IRequest + { + public int ShipmentId { get; set; } + public int StopId { get; set; } + public DateTime Arrived { get; set; } + } + + public class ArriveHandler : IRequestHandler + { + private readonly ShipmentDbContext _dbContext; + + public ArriveHandler(ShipmentDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task Handle(Arrive request, CancellationToken cancellationToken) + { + var allStops = await _dbContext.Stops.Where(x => x.ShipmentId == request.ShipmentId).ToArrayAsync(); + + var stop = allStops.SingleOrDefault(x => x.StopId == request.StopId); + if (stop == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (stop.Status != StopStatus.InTransit) + { + throw new InvalidOperationException("Stop has already arrived."); + } + + var previousStopsAreNotDeparted = allStops + .Where(x => x.Scheduled < stop.Scheduled) + .Any(x => x.Status != StopStatus.Departed); + + if (previousStopsAreNotDeparted) + { + throw new InvalidOperationException("Previous stops have not departed."); + } + + stop.Status = StopStatus.Arrived; + stop.Arrived = request.Arrived; + + await _dbContext.SaveChangesAsync(); + + return Unit.Value; + } + } +} \ No newline at end of file diff --git a/TrxScript/Arrive3.cs b/TrxScript/Arrive3.cs new file mode 100644 index 0000000..a34647f --- /dev/null +++ b/TrxScript/Arrive3.cs @@ -0,0 +1,64 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Demo; +using Demo.EventSourced; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace TransactionScriptVsDomain.TrxScript.Arrive3 +{ + public class Arrive : IRequest + { + public int ShipmentId { get; set; } + public int StopId { get; set; } + public DateTime Arrived { get; set; } + } + + public class ArriveHandler : IRequestHandler + { + private readonly ShipmentDbContext _dbContext; + private readonly IBus _bus; + + public ArriveHandler(ShipmentDbContext dbContext, IBus bus) + { + _dbContext = dbContext; + _bus = bus; + } + + public async Task Handle(Arrive request, CancellationToken cancellationToken) + { + var allStops = await _dbContext.Stops.Where(x => x.ShipmentId == request.ShipmentId).ToArrayAsync(); + + var stop = allStops.SingleOrDefault(x => x.StopId == request.StopId); + if (stop == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (stop.Status != StopStatus.InTransit) + { + throw new InvalidOperationException("Stop has already arrived."); + } + + var previousStopsAreNotDeparted = allStops + .Where(x => x.Scheduled < stop.Scheduled) + .Any(x => x.Status != StopStatus.Departed); + + if (previousStopsAreNotDeparted) + { + throw new InvalidOperationException("Previous stops have not departed."); + } + + stop.Status = StopStatus.Arrived; + stop.Arrived = request.Arrived; + + await _dbContext.SaveChangesAsync(); + + await _bus.Publish(new Arrived(stop.StopId, stop.Arrived)); + + return Unit.Value; + } + } +} \ No newline at end of file diff --git a/TrxScript/Pickup.cs b/TrxScript/Pickup.cs new file mode 100644 index 0000000..973eabe --- /dev/null +++ b/TrxScript/Pickup.cs @@ -0,0 +1,64 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Demo; +using Demo.EventSourced; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace TransactionScriptVsDomain.TrxScript +{ + public class Pickup : IRequest + { + public int ShipmentId { get; set; } + public int StopId { get; set; } + public DateTime Departed { get; set; } + } + + public class PickupHandler : IRequestHandler + { + private readonly ShipmentDbContext _dbContext; + private readonly IBus _bus; + + public PickupHandler(ShipmentDbContext dbContext, IBus bus) + { + _dbContext = dbContext; + _bus = bus; + } + + public async Task Handle(Pickup request, CancellationToken cancellationToken) + { + var stop = await _dbContext.Stops.SingleOrDefaultAsync(x => x.StopId == request.StopId); + + if (stop == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (stop.Status == StopStatus.Departed) + { + throw new InvalidOperationException("Stop has already departed."); + } + + if (stop.Status == StopStatus.InTransit) + { + throw new InvalidOperationException("Stop hasn't arrived yet."); + } + + if (request.Departed < stop.Arrived) + { + throw new InvalidOperationException("Departed Date/Time cannot be before Arrived Date/Time."); + } + + stop.Status = StopStatus.Departed; + stop.Departed = request.Departed; + + await _dbContext.SaveChangesAsync(); + + await _bus.Publish(new PickedUp(request.StopId, request.Departed)); + + return Unit.Value; + } + } +} \ 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/Events.cs b/Events.cs new file mode 100644 index 0000000..161233b --- /dev/null +++ b/Events.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; + +namespace Demo.EventSourced +{ + public interface IEvent { } + + public record Dispatched(int ShipmentId, IEnumerable Stops, DateTime Start) : IEvent; + + public record Arrived(int StopId, DateTime Arrival) : IEvent; + + public record ArrivedLate(int StopId, TimeSpan Delay) : 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/ShipmentAggregateRoot.cs b/ShipmentAggregateRoot.cs new file mode 100644 index 0000000..898f6dc --- /dev/null +++ b/ShipmentAggregateRoot.cs @@ -0,0 +1,203 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Demo.EventSourced; + +namespace Demo +{ + public class ShipmentAggregateRoot + { + private readonly SortedList _stops = new(); + private readonly List _events = new(); + + private ShipmentAggregateRoot(IReadOnlyList stops) + { + for(var x = 0; x < stops.Count; x++) + { + _stops.Add(x, stops[x]); + } + } + + public static ShipmentAggregateRoot Factory(PickupStop pickup, DeliveryStop delivery) + { + return new ShipmentAggregateRoot(new Stop[] { pickup, delivery }); + } + + public static ShipmentAggregateRoot Factory(Stop[] stops) + { + if (stops.Length < 2) + { + throw new InvalidOperationException("Shipment requires at least 2 stops."); + } + + if (stops.First() is not PickupStop) + { + throw new InvalidOperationException("First stop must be a Pickup"); + } + + if (stops.Last() is not DeliveryStop) + { + throw new InvalidOperationException("first stop must be a Pickup"); + } + + return new ShipmentAggregateRoot(stops); + } + + public List GetUncommittedEvents() + { + var evnts = new List(_events); + _events.Clear(); + return evnts; + } + + public void Arrive(int stopId, DateTime arrived) + { + var currentStop = _stops.SingleOrDefault(x => x.Value.StopId == stopId); + if (currentStop.Value == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + var previousStopsAreNotDeparted = _stops.Any(x => x.Key < currentStop.Key && x.Value.Status != StopStatus.Departed); + if (previousStopsAreNotDeparted) + { + throw new InvalidOperationException("Previous stops have not departed."); + } + + currentStop.Value.Arrive(arrived); + + _events.Add(new Arrived(stopId, arrived)); + + var arrivedToScheduled = arrived - currentStop.Value.Scheduled; + if (arrivedToScheduled.TotalHours > 0) + { + _events.Add(new ArrivedLate(stopId, arrivedToScheduled)); + } + } + + public void Pickup(int stopId, DateTime departed) + { + var currentStop = _stops.SingleOrDefault(x => x.Value.StopId == stopId); + if (currentStop.Value == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (currentStop.Value is not PickupStop) + { + throw new InvalidOperationException("Stop is not a pickup."); + } + + if (currentStop.Value.Status != StopStatus.Arrived) + { + Arrive(stopId, departed); + } + + currentStop.Value.Depart(departed); + _events.Add(new PickedUp(stopId, departed)); + } + + public void Deliver(int stopId, DateTime departed) + { + var currentStop = _stops.SingleOrDefault(x => x.Value.StopId == stopId); + if (currentStop.Value == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (currentStop.Value is not DeliveryStop) + { + throw new InvalidOperationException("Stop is not a delivery."); + } + + if (currentStop.Value.Status != StopStatus.Arrived) + { + Arrive(stopId, departed); + } + + currentStop.Value.Depart(departed); + _events.Add(new Delivered(stopId, departed)); + } + + public bool IsComplete() + { + return _stops.All(x => x.Value.Status == StopStatus.Departed); + } + } + + public class PickupStop : Stop + { + public PickupStop(int stopId) + { + StopId = stopId; + } + } + + public class DeliveryStop : Stop + { + public DeliveryStop(int stopId) + { + StopId = stopId; + } + } + + public record ShipmentStop(int StopId, StopType StopType, int Sequence); + + public abstract class Stop + { + public int StopId { get; protected set; } + public StopStatus Status { get; private set; } = StopStatus.InTransit; + public Address Address { get; protected set;} + public DateTime Scheduled { get; protected set;} + public DateTime Arrived { get; protected set; } + public DateTime? Departed { get; protected set; } + + public void Arrive(DateTime arrived) + { + if (Status != StopStatus.InTransit) + { + throw new InvalidOperationException("Stop has already arrived."); + } + + Status = StopStatus.Arrived; + Arrived = arrived; + } + + public void Depart(DateTime departed) + { + if (Status == StopStatus.Departed) + { + throw new InvalidOperationException("Stop has already departed."); + } + + if (departed < Arrived) + { + throw new InvalidOperationException("Departed Date/Time cannot be before Arrived Date/Time."); + } + + Status = StopStatus.Departed; + Departed = departed; + } + } + + public enum StopType + { + Pickup, + Delivery + } + + public enum StopStatus + { + InTransit, + Arrived, + Departed + } + + public record Address(string Street, string City, string Postal, string Country); + + public interface IBus + { + Task Publish(IEvent evnt); + } +} diff --git a/Tests.cs b/Tests.cs new file mode 100644 index 0000000..b179028 --- /dev/null +++ b/Tests.cs @@ -0,0 +1,131 @@ +using System; +using System.Linq; +using Demo.EventSourced; +using Shouldly; +using Xunit; + +namespace Demo +{ + public class Tests + { + private readonly ShipmentAggregateRoot _shipmentAggregateRoot; + private readonly DateTime _arriveShipperDateTime; + private readonly DateTime _pickupShipperDateTime; + private readonly DateTime _arriveDestinationDateTime; + private readonly DateTime _deliveryDestinationDateTime; + + public Tests() + { + var pickup = new PickupStop(1); + var delivery = new DeliveryStop(2); + _shipmentAggregateRoot = ShipmentAggregateRoot.Factory(pickup, delivery); + + _arriveShipperDateTime = new DateTime(2021, 1, 23, 13, 30, 00); + _pickupShipperDateTime = new DateTime(2021, 1, 23, 13, 32, 00); + _arriveDestinationDateTime = new DateTime(2021, 1, 23, 14, 05, 00); + _deliveryDestinationDateTime = new DateTime(2021, 1, 23, 14, 07, 00); + } + + [Fact] + public void CompleteShipment() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + _shipmentAggregateRoot.Arrive(2, _arriveDestinationDateTime); + _shipmentAggregateRoot.Deliver(2, _deliveryDestinationDateTime); + _shipmentAggregateRoot.IsComplete().ShouldBeTrue(); + } + + [Fact] + public void CanPickupWithoutArriving() + { + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + var evnts = _shipmentAggregateRoot.GetUncommittedEvents(); + evnts.OfType().Count().ShouldBe(1); + } + + [Fact] + public void PickupWithoutArriving() + { + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + + var evnts = _shipmentAggregateRoot.GetUncommittedEvents(); + evnts.OfType().Count().ShouldBe(1); + evnts.OfType().Count().ShouldBe(1); + } + + [Fact] + public void CanDeliverWithoutArriving() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + _shipmentAggregateRoot.Deliver(2, _deliveryDestinationDateTime); + + var evnts = _shipmentAggregateRoot.GetUncommittedEvents(); + evnts.OfType().Count().ShouldBe(2); + evnts.OfType().Count().ShouldBe(1); + } + + [Fact] + public void CannotPickupAtDelivery() + { + Should.Throw(() => _shipmentAggregateRoot.Pickup(2, _pickupShipperDateTime), "Stop is not a delivery."); + } + + [Fact] + public void CannotDeliverAtPickup() + { + Should.Throw(() => _shipmentAggregateRoot.Deliver(1, _deliveryDestinationDateTime), "Stop is not a pickup."); + } + + [Fact] + public void ArriveStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRoot.Arrive(0, _arriveShipperDateTime), "Stop does not exist."); + } + + [Fact] + public void PickupStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRoot.Pickup(0, _pickupShipperDateTime), "Stop does not exist."); + } + + [Fact] + public void DeliverStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRoot.Deliver(0, _deliveryDestinationDateTime), "Stop does not exist."); + } + + [Fact] + public void ArriveNonDepartedStops() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + Should.Throw(() => _shipmentAggregateRoot.Arrive(2, _arriveDestinationDateTime), "Previous stops have not departed."); + } + + [Fact] + public void AlreadyArrived() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + Should.Throw(() => _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime), "Stop has already arrived."); + } + + [Fact] + public void AlreadyPickedUp() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + Should.Throw(() => _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime), "Stop has already departed."); + } + + [Fact] + public void AlreadyDelivered() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + _shipmentAggregateRoot.Arrive(2, _arriveDestinationDateTime); + _shipmentAggregateRoot.Deliver(2, _deliveryDestinationDateTime); + Should.Throw(() => _shipmentAggregateRoot.Deliver(2, _deliveryDestinationDateTime), "Stop has already departed."); + } + } +} \ No newline at end of file diff --git a/TransactionScriptVsDomain.csproj b/TransactionScriptVsDomain.csproj new file mode 100644 index 0000000..75b0bd1 --- /dev/null +++ b/TransactionScriptVsDomain.csproj @@ -0,0 +1,15 @@ + + + + net6.0 + + + + + + + + + + + diff --git a/TrxScript/Arrive.cs b/TrxScript/Arrive.cs new file mode 100644 index 0000000..a5b1235 --- /dev/null +++ b/TrxScript/Arrive.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Demo; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace TransactionScriptVsDomain.TrxScript +{ + public class Arrive : IRequest + { + public int StopId { get; set; } + public DateTime Arrived { get; set; } + } + + public class ArriveHandler : IRequestHandler + { + private readonly ShipmentDbContext _dbContext; + + public ArriveHandler(ShipmentDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task Handle(Arrive request, CancellationToken cancellationToken) + { + var stop = await _dbContext.Stops.SingleOrDefaultAsync(x => x.StopId == request.StopId); + if (stop == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (stop.Status != StopStatus.InTransit) + { + throw new InvalidOperationException("Stop has already arrived."); + } + + stop.Status = StopStatus.Arrived; + stop.Arrived = request.Arrived; + + await _dbContext.SaveChangesAsync(); + + return Unit.Value; + } + } +} \ No newline at end of file diff --git a/TrxScript/Arrive2.cs b/TrxScript/Arrive2.cs new file mode 100644 index 0000000..ba52aa6 --- /dev/null +++ b/TrxScript/Arrive2.cs @@ -0,0 +1,59 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Demo; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace TransactionScriptVsDomain.TrxScript.Arrive2 +{ + public class Arrive : IRequest + { + public int ShipmentId { get; set; } + public int StopId { get; set; } + public DateTime Arrived { get; set; } + } + + public class ArriveHandler : IRequestHandler + { + private readonly ShipmentDbContext _dbContext; + + public ArriveHandler(ShipmentDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task Handle(Arrive request, CancellationToken cancellationToken) + { + var allStops = await _dbContext.Stops.Where(x => x.ShipmentId == request.ShipmentId).ToArrayAsync(); + + var stop = allStops.SingleOrDefault(x => x.StopId == request.StopId); + if (stop == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (stop.Status != StopStatus.InTransit) + { + throw new InvalidOperationException("Stop has already arrived."); + } + + var previousStopsAreNotDeparted = allStops + .Where(x => x.Scheduled < stop.Scheduled) + .Any(x => x.Status != StopStatus.Departed); + + if (previousStopsAreNotDeparted) + { + throw new InvalidOperationException("Previous stops have not departed."); + } + + stop.Status = StopStatus.Arrived; + stop.Arrived = request.Arrived; + + await _dbContext.SaveChangesAsync(); + + return Unit.Value; + } + } +} \ No newline at end of file diff --git a/TrxScript/Arrive3.cs b/TrxScript/Arrive3.cs new file mode 100644 index 0000000..a34647f --- /dev/null +++ b/TrxScript/Arrive3.cs @@ -0,0 +1,64 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Demo; +using Demo.EventSourced; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace TransactionScriptVsDomain.TrxScript.Arrive3 +{ + public class Arrive : IRequest + { + public int ShipmentId { get; set; } + public int StopId { get; set; } + public DateTime Arrived { get; set; } + } + + public class ArriveHandler : IRequestHandler + { + private readonly ShipmentDbContext _dbContext; + private readonly IBus _bus; + + public ArriveHandler(ShipmentDbContext dbContext, IBus bus) + { + _dbContext = dbContext; + _bus = bus; + } + + public async Task Handle(Arrive request, CancellationToken cancellationToken) + { + var allStops = await _dbContext.Stops.Where(x => x.ShipmentId == request.ShipmentId).ToArrayAsync(); + + var stop = allStops.SingleOrDefault(x => x.StopId == request.StopId); + if (stop == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (stop.Status != StopStatus.InTransit) + { + throw new InvalidOperationException("Stop has already arrived."); + } + + var previousStopsAreNotDeparted = allStops + .Where(x => x.Scheduled < stop.Scheduled) + .Any(x => x.Status != StopStatus.Departed); + + if (previousStopsAreNotDeparted) + { + throw new InvalidOperationException("Previous stops have not departed."); + } + + stop.Status = StopStatus.Arrived; + stop.Arrived = request.Arrived; + + await _dbContext.SaveChangesAsync(); + + await _bus.Publish(new Arrived(stop.StopId, stop.Arrived)); + + return Unit.Value; + } + } +} \ No newline at end of file diff --git a/TrxScript/Pickup.cs b/TrxScript/Pickup.cs new file mode 100644 index 0000000..973eabe --- /dev/null +++ b/TrxScript/Pickup.cs @@ -0,0 +1,64 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Demo; +using Demo.EventSourced; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace TransactionScriptVsDomain.TrxScript +{ + public class Pickup : IRequest + { + public int ShipmentId { get; set; } + public int StopId { get; set; } + public DateTime Departed { get; set; } + } + + public class PickupHandler : IRequestHandler + { + private readonly ShipmentDbContext _dbContext; + private readonly IBus _bus; + + public PickupHandler(ShipmentDbContext dbContext, IBus bus) + { + _dbContext = dbContext; + _bus = bus; + } + + public async Task Handle(Pickup request, CancellationToken cancellationToken) + { + var stop = await _dbContext.Stops.SingleOrDefaultAsync(x => x.StopId == request.StopId); + + if (stop == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (stop.Status == StopStatus.Departed) + { + throw new InvalidOperationException("Stop has already departed."); + } + + if (stop.Status == StopStatus.InTransit) + { + throw new InvalidOperationException("Stop hasn't arrived yet."); + } + + if (request.Departed < stop.Arrived) + { + throw new InvalidOperationException("Departed Date/Time cannot be before Arrived Date/Time."); + } + + stop.Status = StopStatus.Departed; + stop.Departed = request.Departed; + + await _dbContext.SaveChangesAsync(); + + await _bus.Publish(new PickedUp(request.StopId, request.Departed)); + + return Unit.Value; + } + } +} \ No newline at end of file diff --git a/TrxScript/Pickup2.cs b/TrxScript/Pickup2.cs new file mode 100644 index 0000000..7b383e2 --- /dev/null +++ b/TrxScript/Pickup2.cs @@ -0,0 +1,40 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Demo; +using Demo.EventSourced; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Client; +using IRequest = MediatR.IRequest; + +namespace TransactionScriptVsDomain.TrxScript.Pickup2 +{ + public class Pickup : IRequest + { + public int ShipmentId { get; set; } + public int StopId { get; set; } + public DateTime Departed { get; set; } + } + + public class PickupHandler : IRequestHandler + { + private readonly IShipmentRepository _shipmentRepository; + + public PickupHandler(IShipmentRepository shipmentRepository) + { + _shipmentRepository = shipmentRepository; + } + + public async Task Handle(Pickup request, CancellationToken cancellationToken) + { + var shipment = await _shipmentRepository.Get(request.ShipmentId); + shipment.Pickup(request.StopId, request.Departed); + + await _shipmentRepository.Save(shipment); + + return Unit.Value; + } + } +} \ 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/Events.cs b/Events.cs new file mode 100644 index 0000000..161233b --- /dev/null +++ b/Events.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; + +namespace Demo.EventSourced +{ + public interface IEvent { } + + public record Dispatched(int ShipmentId, IEnumerable Stops, DateTime Start) : IEvent; + + public record Arrived(int StopId, DateTime Arrival) : IEvent; + + public record ArrivedLate(int StopId, TimeSpan Delay) : 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/ShipmentAggregateRoot.cs b/ShipmentAggregateRoot.cs new file mode 100644 index 0000000..898f6dc --- /dev/null +++ b/ShipmentAggregateRoot.cs @@ -0,0 +1,203 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Demo.EventSourced; + +namespace Demo +{ + public class ShipmentAggregateRoot + { + private readonly SortedList _stops = new(); + private readonly List _events = new(); + + private ShipmentAggregateRoot(IReadOnlyList stops) + { + for(var x = 0; x < stops.Count; x++) + { + _stops.Add(x, stops[x]); + } + } + + public static ShipmentAggregateRoot Factory(PickupStop pickup, DeliveryStop delivery) + { + return new ShipmentAggregateRoot(new Stop[] { pickup, delivery }); + } + + public static ShipmentAggregateRoot Factory(Stop[] stops) + { + if (stops.Length < 2) + { + throw new InvalidOperationException("Shipment requires at least 2 stops."); + } + + if (stops.First() is not PickupStop) + { + throw new InvalidOperationException("First stop must be a Pickup"); + } + + if (stops.Last() is not DeliveryStop) + { + throw new InvalidOperationException("first stop must be a Pickup"); + } + + return new ShipmentAggregateRoot(stops); + } + + public List GetUncommittedEvents() + { + var evnts = new List(_events); + _events.Clear(); + return evnts; + } + + public void Arrive(int stopId, DateTime arrived) + { + var currentStop = _stops.SingleOrDefault(x => x.Value.StopId == stopId); + if (currentStop.Value == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + var previousStopsAreNotDeparted = _stops.Any(x => x.Key < currentStop.Key && x.Value.Status != StopStatus.Departed); + if (previousStopsAreNotDeparted) + { + throw new InvalidOperationException("Previous stops have not departed."); + } + + currentStop.Value.Arrive(arrived); + + _events.Add(new Arrived(stopId, arrived)); + + var arrivedToScheduled = arrived - currentStop.Value.Scheduled; + if (arrivedToScheduled.TotalHours > 0) + { + _events.Add(new ArrivedLate(stopId, arrivedToScheduled)); + } + } + + public void Pickup(int stopId, DateTime departed) + { + var currentStop = _stops.SingleOrDefault(x => x.Value.StopId == stopId); + if (currentStop.Value == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (currentStop.Value is not PickupStop) + { + throw new InvalidOperationException("Stop is not a pickup."); + } + + if (currentStop.Value.Status != StopStatus.Arrived) + { + Arrive(stopId, departed); + } + + currentStop.Value.Depart(departed); + _events.Add(new PickedUp(stopId, departed)); + } + + public void Deliver(int stopId, DateTime departed) + { + var currentStop = _stops.SingleOrDefault(x => x.Value.StopId == stopId); + if (currentStop.Value == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (currentStop.Value is not DeliveryStop) + { + throw new InvalidOperationException("Stop is not a delivery."); + } + + if (currentStop.Value.Status != StopStatus.Arrived) + { + Arrive(stopId, departed); + } + + currentStop.Value.Depart(departed); + _events.Add(new Delivered(stopId, departed)); + } + + public bool IsComplete() + { + return _stops.All(x => x.Value.Status == StopStatus.Departed); + } + } + + public class PickupStop : Stop + { + public PickupStop(int stopId) + { + StopId = stopId; + } + } + + public class DeliveryStop : Stop + { + public DeliveryStop(int stopId) + { + StopId = stopId; + } + } + + public record ShipmentStop(int StopId, StopType StopType, int Sequence); + + public abstract class Stop + { + public int StopId { get; protected set; } + public StopStatus Status { get; private set; } = StopStatus.InTransit; + public Address Address { get; protected set;} + public DateTime Scheduled { get; protected set;} + public DateTime Arrived { get; protected set; } + public DateTime? Departed { get; protected set; } + + public void Arrive(DateTime arrived) + { + if (Status != StopStatus.InTransit) + { + throw new InvalidOperationException("Stop has already arrived."); + } + + Status = StopStatus.Arrived; + Arrived = arrived; + } + + public void Depart(DateTime departed) + { + if (Status == StopStatus.Departed) + { + throw new InvalidOperationException("Stop has already departed."); + } + + if (departed < Arrived) + { + throw new InvalidOperationException("Departed Date/Time cannot be before Arrived Date/Time."); + } + + Status = StopStatus.Departed; + Departed = departed; + } + } + + public enum StopType + { + Pickup, + Delivery + } + + public enum StopStatus + { + InTransit, + Arrived, + Departed + } + + public record Address(string Street, string City, string Postal, string Country); + + public interface IBus + { + Task Publish(IEvent evnt); + } +} diff --git a/Tests.cs b/Tests.cs new file mode 100644 index 0000000..b179028 --- /dev/null +++ b/Tests.cs @@ -0,0 +1,131 @@ +using System; +using System.Linq; +using Demo.EventSourced; +using Shouldly; +using Xunit; + +namespace Demo +{ + public class Tests + { + private readonly ShipmentAggregateRoot _shipmentAggregateRoot; + private readonly DateTime _arriveShipperDateTime; + private readonly DateTime _pickupShipperDateTime; + private readonly DateTime _arriveDestinationDateTime; + private readonly DateTime _deliveryDestinationDateTime; + + public Tests() + { + var pickup = new PickupStop(1); + var delivery = new DeliveryStop(2); + _shipmentAggregateRoot = ShipmentAggregateRoot.Factory(pickup, delivery); + + _arriveShipperDateTime = new DateTime(2021, 1, 23, 13, 30, 00); + _pickupShipperDateTime = new DateTime(2021, 1, 23, 13, 32, 00); + _arriveDestinationDateTime = new DateTime(2021, 1, 23, 14, 05, 00); + _deliveryDestinationDateTime = new DateTime(2021, 1, 23, 14, 07, 00); + } + + [Fact] + public void CompleteShipment() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + _shipmentAggregateRoot.Arrive(2, _arriveDestinationDateTime); + _shipmentAggregateRoot.Deliver(2, _deliveryDestinationDateTime); + _shipmentAggregateRoot.IsComplete().ShouldBeTrue(); + } + + [Fact] + public void CanPickupWithoutArriving() + { + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + var evnts = _shipmentAggregateRoot.GetUncommittedEvents(); + evnts.OfType().Count().ShouldBe(1); + } + + [Fact] + public void PickupWithoutArriving() + { + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + + var evnts = _shipmentAggregateRoot.GetUncommittedEvents(); + evnts.OfType().Count().ShouldBe(1); + evnts.OfType().Count().ShouldBe(1); + } + + [Fact] + public void CanDeliverWithoutArriving() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + _shipmentAggregateRoot.Deliver(2, _deliveryDestinationDateTime); + + var evnts = _shipmentAggregateRoot.GetUncommittedEvents(); + evnts.OfType().Count().ShouldBe(2); + evnts.OfType().Count().ShouldBe(1); + } + + [Fact] + public void CannotPickupAtDelivery() + { + Should.Throw(() => _shipmentAggregateRoot.Pickup(2, _pickupShipperDateTime), "Stop is not a delivery."); + } + + [Fact] + public void CannotDeliverAtPickup() + { + Should.Throw(() => _shipmentAggregateRoot.Deliver(1, _deliveryDestinationDateTime), "Stop is not a pickup."); + } + + [Fact] + public void ArriveStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRoot.Arrive(0, _arriveShipperDateTime), "Stop does not exist."); + } + + [Fact] + public void PickupStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRoot.Pickup(0, _pickupShipperDateTime), "Stop does not exist."); + } + + [Fact] + public void DeliverStopDoesNotExist() + { + Should.Throw(() => _shipmentAggregateRoot.Deliver(0, _deliveryDestinationDateTime), "Stop does not exist."); + } + + [Fact] + public void ArriveNonDepartedStops() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + Should.Throw(() => _shipmentAggregateRoot.Arrive(2, _arriveDestinationDateTime), "Previous stops have not departed."); + } + + [Fact] + public void AlreadyArrived() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + Should.Throw(() => _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime), "Stop has already arrived."); + } + + [Fact] + public void AlreadyPickedUp() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + Should.Throw(() => _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime), "Stop has already departed."); + } + + [Fact] + public void AlreadyDelivered() + { + _shipmentAggregateRoot.Arrive(1, _arriveShipperDateTime); + _shipmentAggregateRoot.Pickup(1, _pickupShipperDateTime); + _shipmentAggregateRoot.Arrive(2, _arriveDestinationDateTime); + _shipmentAggregateRoot.Deliver(2, _deliveryDestinationDateTime); + Should.Throw(() => _shipmentAggregateRoot.Deliver(2, _deliveryDestinationDateTime), "Stop has already departed."); + } + } +} \ No newline at end of file diff --git a/TransactionScriptVsDomain.csproj b/TransactionScriptVsDomain.csproj new file mode 100644 index 0000000..75b0bd1 --- /dev/null +++ b/TransactionScriptVsDomain.csproj @@ -0,0 +1,15 @@ + + + + net6.0 + + + + + + + + + + + diff --git a/TrxScript/Arrive.cs b/TrxScript/Arrive.cs new file mode 100644 index 0000000..a5b1235 --- /dev/null +++ b/TrxScript/Arrive.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Demo; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace TransactionScriptVsDomain.TrxScript +{ + public class Arrive : IRequest + { + public int StopId { get; set; } + public DateTime Arrived { get; set; } + } + + public class ArriveHandler : IRequestHandler + { + private readonly ShipmentDbContext _dbContext; + + public ArriveHandler(ShipmentDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task Handle(Arrive request, CancellationToken cancellationToken) + { + var stop = await _dbContext.Stops.SingleOrDefaultAsync(x => x.StopId == request.StopId); + if (stop == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (stop.Status != StopStatus.InTransit) + { + throw new InvalidOperationException("Stop has already arrived."); + } + + stop.Status = StopStatus.Arrived; + stop.Arrived = request.Arrived; + + await _dbContext.SaveChangesAsync(); + + return Unit.Value; + } + } +} \ No newline at end of file diff --git a/TrxScript/Arrive2.cs b/TrxScript/Arrive2.cs new file mode 100644 index 0000000..ba52aa6 --- /dev/null +++ b/TrxScript/Arrive2.cs @@ -0,0 +1,59 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Demo; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace TransactionScriptVsDomain.TrxScript.Arrive2 +{ + public class Arrive : IRequest + { + public int ShipmentId { get; set; } + public int StopId { get; set; } + public DateTime Arrived { get; set; } + } + + public class ArriveHandler : IRequestHandler + { + private readonly ShipmentDbContext _dbContext; + + public ArriveHandler(ShipmentDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task Handle(Arrive request, CancellationToken cancellationToken) + { + var allStops = await _dbContext.Stops.Where(x => x.ShipmentId == request.ShipmentId).ToArrayAsync(); + + var stop = allStops.SingleOrDefault(x => x.StopId == request.StopId); + if (stop == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (stop.Status != StopStatus.InTransit) + { + throw new InvalidOperationException("Stop has already arrived."); + } + + var previousStopsAreNotDeparted = allStops + .Where(x => x.Scheduled < stop.Scheduled) + .Any(x => x.Status != StopStatus.Departed); + + if (previousStopsAreNotDeparted) + { + throw new InvalidOperationException("Previous stops have not departed."); + } + + stop.Status = StopStatus.Arrived; + stop.Arrived = request.Arrived; + + await _dbContext.SaveChangesAsync(); + + return Unit.Value; + } + } +} \ No newline at end of file diff --git a/TrxScript/Arrive3.cs b/TrxScript/Arrive3.cs new file mode 100644 index 0000000..a34647f --- /dev/null +++ b/TrxScript/Arrive3.cs @@ -0,0 +1,64 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Demo; +using Demo.EventSourced; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace TransactionScriptVsDomain.TrxScript.Arrive3 +{ + public class Arrive : IRequest + { + public int ShipmentId { get; set; } + public int StopId { get; set; } + public DateTime Arrived { get; set; } + } + + public class ArriveHandler : IRequestHandler + { + private readonly ShipmentDbContext _dbContext; + private readonly IBus _bus; + + public ArriveHandler(ShipmentDbContext dbContext, IBus bus) + { + _dbContext = dbContext; + _bus = bus; + } + + public async Task Handle(Arrive request, CancellationToken cancellationToken) + { + var allStops = await _dbContext.Stops.Where(x => x.ShipmentId == request.ShipmentId).ToArrayAsync(); + + var stop = allStops.SingleOrDefault(x => x.StopId == request.StopId); + if (stop == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (stop.Status != StopStatus.InTransit) + { + throw new InvalidOperationException("Stop has already arrived."); + } + + var previousStopsAreNotDeparted = allStops + .Where(x => x.Scheduled < stop.Scheduled) + .Any(x => x.Status != StopStatus.Departed); + + if (previousStopsAreNotDeparted) + { + throw new InvalidOperationException("Previous stops have not departed."); + } + + stop.Status = StopStatus.Arrived; + stop.Arrived = request.Arrived; + + await _dbContext.SaveChangesAsync(); + + await _bus.Publish(new Arrived(stop.StopId, stop.Arrived)); + + return Unit.Value; + } + } +} \ No newline at end of file diff --git a/TrxScript/Pickup.cs b/TrxScript/Pickup.cs new file mode 100644 index 0000000..973eabe --- /dev/null +++ b/TrxScript/Pickup.cs @@ -0,0 +1,64 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Demo; +using Demo.EventSourced; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace TransactionScriptVsDomain.TrxScript +{ + public class Pickup : IRequest + { + public int ShipmentId { get; set; } + public int StopId { get; set; } + public DateTime Departed { get; set; } + } + + public class PickupHandler : IRequestHandler + { + private readonly ShipmentDbContext _dbContext; + private readonly IBus _bus; + + public PickupHandler(ShipmentDbContext dbContext, IBus bus) + { + _dbContext = dbContext; + _bus = bus; + } + + public async Task Handle(Pickup request, CancellationToken cancellationToken) + { + var stop = await _dbContext.Stops.SingleOrDefaultAsync(x => x.StopId == request.StopId); + + if (stop == null) + { + throw new InvalidOperationException("Stop does not exist."); + } + + if (stop.Status == StopStatus.Departed) + { + throw new InvalidOperationException("Stop has already departed."); + } + + if (stop.Status == StopStatus.InTransit) + { + throw new InvalidOperationException("Stop hasn't arrived yet."); + } + + if (request.Departed < stop.Arrived) + { + throw new InvalidOperationException("Departed Date/Time cannot be before Arrived Date/Time."); + } + + stop.Status = StopStatus.Departed; + stop.Departed = request.Departed; + + await _dbContext.SaveChangesAsync(); + + await _bus.Publish(new PickedUp(request.StopId, request.Departed)); + + return Unit.Value; + } + } +} \ No newline at end of file diff --git a/TrxScript/Pickup2.cs b/TrxScript/Pickup2.cs new file mode 100644 index 0000000..7b383e2 --- /dev/null +++ b/TrxScript/Pickup2.cs @@ -0,0 +1,40 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Demo; +using Demo.EventSourced; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Client; +using IRequest = MediatR.IRequest; + +namespace TransactionScriptVsDomain.TrxScript.Pickup2 +{ + public class Pickup : IRequest + { + public int ShipmentId { get; set; } + public int StopId { get; set; } + public DateTime Departed { get; set; } + } + + public class PickupHandler : IRequestHandler + { + private readonly IShipmentRepository _shipmentRepository; + + public PickupHandler(IShipmentRepository shipmentRepository) + { + _shipmentRepository = shipmentRepository; + } + + public async Task Handle(Pickup request, CancellationToken cancellationToken) + { + var shipment = await _shipmentRepository.Get(request.ShipmentId); + shipment.Pickup(request.StopId, request.Departed); + + await _shipmentRepository.Save(shipment); + + return Unit.Value; + } + } +} \ No newline at end of file diff --git a/TrxScript/ShipmentDbContext.cs b/TrxScript/ShipmentDbContext.cs new file mode 100644 index 0000000..a6d3b5c --- /dev/null +++ b/TrxScript/ShipmentDbContext.cs @@ -0,0 +1,27 @@ +using System; +using System.Threading.Tasks; +using Demo; +using Microsoft.EntityFrameworkCore; + +namespace TransactionScriptVsDomain.TrxScript; + +public class ShipmentDbContext : DbContext +{ + public DbSet Stops { get; set; } +} + +public class StopDataModel +{ + public int ShipmentId { get; set; } + public int StopId { get; set; } + public StopStatus Status { get; set; } + public DateTime Scheduled { get; set;} + public DateTime Arrived { get; set; } + public DateTime? Departed { get; set; } +} + +public interface IShipmentRepository +{ + public Task Get(int shipmentId); + public Task Save(ShipmentAggregateRoot shipmentAggregateRoot); +} \ No newline at end of file