├── .gitattributes ├── .gitignore ├── Library ├── Azure │ ├── Microsoft.WindowsAzure.StorageClient.dll │ ├── Microsoft.WindowsAzure.StorageClient.xml │ └── ReadMe.txt ├── NUnit │ ├── nunit.framework.dll │ └── nunit.framework.xml └── mysql │ ├── mysql.data.dll │ └── mysql.data.entity.dll ├── README.md ├── Sample ├── Domain │ ├── Contracts.cs │ ├── CustomerAggregate │ │ ├── Customer.cs │ │ ├── CustomerApplicationService.cs │ │ └── CustomerState.cs │ ├── IPricingService.cs │ └── ReadMe.md ├── Interfaces.cs ├── LoggingWrapper.cs ├── Program.cs ├── Projections │ ├── CustomerListProjection.cs │ ├── IDocumentWriter.cs │ └── ReadMe.md ├── ReadMe.md ├── RunMe.csproj └── Storage │ ├── AppendOnlyStream.cs │ ├── Azure │ ├── AutoRenewLease.cs │ └── BlobAppendOnlyStore.cs │ ├── EventStore.cs │ ├── Files │ └── FileEventStore.cs │ ├── IAppendOnlyStore.cs │ ├── IEventStore.cs │ ├── MsSql │ └── SqlEventStore.cs │ ├── MySql │ └── MySqlEventStore.cs │ └── ReadMe.md ├── UnitTests ├── CustomerAggregate │ ├── WhenChargeCustomer.cs │ ├── WhenCreateCustomer.cs │ └── WhenRenameCustomer.cs ├── ReadMe.md ├── UnitTests.csproj └── framework.cs └── lokad-iddd-sample.sln /.gitattributes: -------------------------------------------------------------------------------- 1 | *.cscfg text 2 | *.csdef text 3 | *.config text 4 | *.conf text 5 | *.xml text 6 | *.js text 7 | * text=auto -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | obj 2 | bin 3 | *.user 4 | *.suo 5 | *.orig 6 | output 7 | publish 8 | _ReSharper.* 9 | *.Resharper 10 | *.DotSettings 11 | *.Cache 12 | *.cache 13 | -------------------------------------------------------------------------------- /Library/Azure/Microsoft.WindowsAzure.StorageClient.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abdullin/iddd-sample/577ed500183fe9cadf1e3ad0a40336b94d10611a/Library/Azure/Microsoft.WindowsAzure.StorageClient.dll -------------------------------------------------------------------------------- /Library/Azure/ReadMe.txt: -------------------------------------------------------------------------------- 1 | Version 1.6 November -------------------------------------------------------------------------------- /Library/NUnit/nunit.framework.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abdullin/iddd-sample/577ed500183fe9cadf1e3ad0a40336b94d10611a/Library/NUnit/nunit.framework.dll -------------------------------------------------------------------------------- /Library/mysql/mysql.data.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abdullin/iddd-sample/577ed500183fe9cadf1e3ad0a40336b94d10611a/Library/mysql/mysql.data.dll -------------------------------------------------------------------------------- /Library/mysql/mysql.data.entity.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abdullin/iddd-sample/577ed500183fe9cadf1e3ad0a40336b94d10611a/Library/mysql/mysql.data.entity.dll -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abdullin/iddd-sample/577ed500183fe9cadf1e3ad0a40336b94d10611a/README.md -------------------------------------------------------------------------------- /Sample/Domain/Contracts.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Sample.Domain 4 | { 5 | // This file contains contracts for the sample domain. You can percieve these 6 | // classes and value objects as the foundation of the language, that is used 7 | // to express core business concepts in the code. In Domain-driven design 8 | // such classes become part of the Ubiquituous Language of this bounded context 9 | // (and may be some others). 10 | 11 | // These classes might be simple, but they are extremely important. 12 | // In more serious projects they are generally put into *.Contracts.dll, 13 | // which might be shared with the other bounded contexts. 14 | 15 | // Usually contracts include: 16 | // identities (strongly-typed references to other aggregates) 17 | // value objects 18 | // event and command contracts 19 | 20 | 21 | /// 22 | /// This is a customer identity. It is just a class that makes it explicit, 23 | /// that this specific long is not just any number, but an identifier 24 | /// of a customer aggregate. This has a lot of benefits in further development. 25 | /// 26 | [Serializable] 27 | public struct CustomerId : IIdentity 28 | { 29 | public readonly long Id; 30 | 31 | public CustomerId(long id) 32 | { 33 | Id = id; 34 | } 35 | 36 | public override string ToString() 37 | { 38 | return string.Format("customer-{0}", Id); 39 | } 40 | } 41 | 42 | /// Just a currency enumeration, which is 43 | /// a part of 44 | public enum Currency 45 | { 46 | None, 47 | Eur, 48 | Usd, 49 | Rur 50 | } 51 | 52 | /// 53 | /// A simple helper class that allows to express currency as 54 | /// 3m.Eur() 55 | /// 56 | public static class CurrencyExtension 57 | { 58 | public static CurrencyAmount Eur(this decimal amount) 59 | { 60 | return new CurrencyAmount(amount, Currency.Eur); 61 | } 62 | } 63 | 64 | /// 65 | /// This is an extremely important concept for accounting - amount of money 66 | /// in a specific currency. It helps to ensure that we will never try to add 67 | /// amounts in different currencies. 68 | [Serializable] 69 | public struct CurrencyAmount 70 | { 71 | public readonly decimal Amount; 72 | public readonly Currency Currency; 73 | 74 | public CurrencyAmount(decimal amount, Currency currency) 75 | { 76 | Amount = amount; 77 | Currency = currency; 78 | } 79 | 80 | 81 | 82 | public static bool operator ==(CurrencyAmount left, CurrencyAmount right) 83 | { 84 | left.CheckCurrency(right.Currency, "=="); 85 | return left.Amount == right.Amount; 86 | } 87 | 88 | public static bool operator !=(CurrencyAmount left, CurrencyAmount right) 89 | { 90 | left.CheckCurrency(right.Currency, "!="); 91 | return left.Amount != right.Amount; 92 | } 93 | public static bool operator < (CurrencyAmount left, CurrencyAmount right) 94 | { 95 | left.CheckCurrency(right.Currency, "<"); 96 | return left.Amount < right.Amount; 97 | } 98 | 99 | public static CurrencyAmount operator + (CurrencyAmount left, CurrencyAmount right) 100 | { 101 | left.CheckCurrency(right.Currency, "+"); 102 | return new CurrencyAmount(left.Amount + right.Amount, left.Currency); 103 | } 104 | public static CurrencyAmount operator -(CurrencyAmount left, CurrencyAmount right) 105 | { 106 | left.CheckCurrency(right.Currency, "-"); 107 | return new CurrencyAmount(left.Amount - right.Amount, left.Currency); 108 | } 109 | public static CurrencyAmount operator -(CurrencyAmount right) 110 | { 111 | 112 | return new CurrencyAmount(- right.Amount, right.Currency); 113 | } 114 | 115 | void CheckCurrency(Currency type, string operation) 116 | { 117 | if (Currency == type) return; 118 | throw new InvalidOperationException(string.Format("Can't perform operation on different currencies: {0} {1} {2}", Currency, operation, type)); 119 | } 120 | 121 | public static bool operator >(CurrencyAmount left, CurrencyAmount right) 122 | { 123 | left.CheckCurrency(right.Currency, ">"); 124 | return left.Amount > right.Amount; 125 | } 126 | 127 | public override string ToString() 128 | { 129 | return string.Format("{0:0.##} {1}", Amount, Currency.ToString().ToUpper()); 130 | } 131 | } 132 | 133 | // classes below are events and commands for the current bounded context. 134 | // Normally they are auto-generated by a DSL (see Lokad.CQRS for a sample), 135 | // however in this case we just wrote them manually. 136 | 137 | // each command and event implements ToString method, which generates a human- 138 | // readable represenation of this object. This is EXTREMELY helpful for 139 | // logging, visualizing and testing. 140 | 141 | // such classes should be serializable properly by the current serializer that 142 | // you are using. Normally we use something like ProtoBuf (uses [DataContract] attributes) 143 | // but in this case we are staying simple and use BinarySerializer, which requires 144 | // use of [Serializable] attribute 145 | 146 | 147 | 148 | [Serializable] 149 | public class CustomerCreated : IEvent 150 | { 151 | public string Name { get; set; } 152 | public DateTime Created { get; set; } 153 | public CustomerId Id { get; set; } 154 | public Currency Currency { get; set; } 155 | 156 | public override string ToString() 157 | { 158 | return string.Format("Customer {0} created with {1}", Name, Currency); 159 | } 160 | 161 | } 162 | [Serializable] 163 | public sealed class CreateCustomer : ICommand 164 | { 165 | public CustomerId Id { get; set; } 166 | public string Name { get; set; } 167 | public Currency Currency { get; set; } 168 | 169 | public override string ToString() 170 | { 171 | return string.Format("Create {0} named '{1}' with {2}", Id, Name, Currency); 172 | } 173 | } 174 | [Serializable] 175 | public sealed class AddCustomerPayment : ICommand 176 | { 177 | public CustomerId Id { get; set; } 178 | public string Name { get; set; } 179 | public CurrencyAmount Amount { get; set; } 180 | 181 | public override string ToString() 182 | { 183 | return string.Format("Add {0} - '{1}'", Amount, Name); 184 | } 185 | } 186 | [Serializable] 187 | public sealed class ChargeCustomer : ICommand 188 | { 189 | public CustomerId Id { get; set; } 190 | public string Name { get; set; } 191 | public CurrencyAmount Amount { get; set; } 192 | 193 | public override string ToString() 194 | { 195 | return string.Format("Charge {0} - '{1}'", Amount, Name); 196 | } 197 | } 198 | [Serializable] 199 | public sealed class CustomerPaymentAdded : IEvent 200 | { 201 | public CustomerId Id { get; set; } 202 | public string PaymentName { get; set; } 203 | public CurrencyAmount Payment { get; set; } 204 | public CurrencyAmount NewBalance { get; set; } 205 | public int Transaction { get; set; } 206 | public DateTime TimeUtc { get; set; } 207 | 208 | public override string ToString() 209 | { 210 | return string.Format("Added '{2}' {1} | Tx {0} => {3}", 211 | Transaction, Payment, PaymentName, NewBalance); 212 | } 213 | } 214 | 215 | 216 | [Serializable] 217 | public sealed class CustomerChargeAdded : IEvent 218 | { 219 | public CustomerId Id { get; set; } 220 | public string ChargeName { get; set; } 221 | public CurrencyAmount Charge { get; set; } 222 | public CurrencyAmount NewBalance { get; set; } 223 | public int Transaction { get; set; } 224 | public DateTime TimeUtc { get; set; } 225 | 226 | public override string ToString() 227 | { 228 | return string.Format("Charged '{2}' {1} | Tx {0} => {3}", 229 | Transaction, Charge, ChargeName, NewBalance); 230 | } 231 | 232 | } 233 | [Serializable] 234 | public class RenameCustomer : ICommand 235 | { 236 | public CustomerId Id { get; set; } 237 | public string NewName { get; set; } 238 | 239 | public override string ToString() 240 | { 241 | return string.Format("Rename {0} to '{1}'", Id, NewName); 242 | } 243 | } 244 | [Serializable] 245 | public class LockCustomerForAccountOverdraft : ICommand 246 | { 247 | public CustomerId Id { get; set; } 248 | public string Comment { get; set; } 249 | } 250 | [Serializable] 251 | public class LockCustomer : ICommand 252 | { 253 | public CustomerId Id { get; set; } 254 | public string Reason { get; set; } 255 | } 256 | [Serializable] 257 | public class CustomerLocked : IEvent 258 | { 259 | public CustomerId Id { get; set; } 260 | public string Reason { get; set; } 261 | 262 | public override string ToString() 263 | { 264 | return string.Format("Customer locked: {0}", Reason); 265 | } 266 | } 267 | [Serializable] 268 | public class CustomerRenamed : IEvent 269 | { 270 | public string Name { get; set; } 271 | // normally you don't need old name. But here, 272 | // we include it just for demo 273 | public string OldName { get; set; } 274 | public CustomerId Id { get; set; } 275 | public DateTime Renamed { get; set; } 276 | 277 | public override string ToString() 278 | { 279 | return string.Format("Customer renamed from '{0}' to '{1}'", OldName, Name); 280 | } 281 | } 282 | } -------------------------------------------------------------------------------- /Sample/Domain/CustomerAggregate/Customer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace Sample.Domain 5 | { 6 | /// 7 | /// Implementation of customer aggregate. In production it is loaded and 8 | /// operated by an , which loads it from 9 | /// the event storage and calls appropriate methods, passing needed arguments in. 10 | /// In test environments (e.g. in unit tests), this aggregate can be 11 | /// instantiated directly. 12 | /// 13 | public class Customer 14 | { 15 | /// List of uncommitted changes 16 | public readonly IList Changes = new List(); 17 | /// 18 | /// Aggregate state, which is separate from this class in order to ensure, 19 | /// that we modify it ONLY by passing events. 20 | /// 21 | readonly CustomerState _state; 22 | 23 | public Customer(IEnumerable events) 24 | { 25 | _state = new CustomerState(events); 26 | } 27 | 28 | void Apply(IEvent e) 29 | { 30 | // pass each event to modify current in-memory state 31 | _state.Mutate(e); 32 | // append event to change list for further persistence 33 | Changes.Add(e); 34 | } 35 | 36 | 37 | public void Create(CustomerId id, string name, Currency currency, IPricingService service, DateTime utc) 38 | { 39 | if (_state.Created) 40 | throw new InvalidOperationException("Customer was already created"); 41 | Apply(new CustomerCreated 42 | { 43 | Created = utc, 44 | Name = name, 45 | Id = id, 46 | Currency = currency 47 | }); 48 | 49 | var bonus = service.GetWelcomeBonus(currency); 50 | if (bonus.Amount > 0) 51 | AddPayment("Welcome bonus", bonus, utc); 52 | } 53 | public void Rename(string name, DateTime dateTime) 54 | { 55 | if (_state.Name == name) 56 | return; 57 | Apply(new CustomerRenamed 58 | { 59 | Name = name, 60 | Id = _state.Id, 61 | OldName = _state.Name, 62 | Renamed = dateTime 63 | }); 64 | } 65 | 66 | public void LockCustomer(string reason) 67 | { 68 | if (_state.ConsumptionLocked) 69 | return; 70 | 71 | Apply(new CustomerLocked 72 | { 73 | Id = _state.Id, 74 | Reason = reason 75 | }); 76 | } 77 | 78 | public void LockForAccountOverdraft(string comment, IPricingService service) 79 | { 80 | if (_state.ManualBilling) return; 81 | var threshold = service.GetOverdraftThreshold(_state.Currency); 82 | if (_state.Balance < threshold) 83 | { 84 | LockCustomer("Overdraft. " + comment); 85 | } 86 | 87 | } 88 | 89 | public void AddPayment(string name, CurrencyAmount amount, DateTime utc) 90 | { 91 | Apply(new CustomerPaymentAdded() 92 | { 93 | Id = _state.Id, 94 | Payment = amount, 95 | NewBalance = _state.Balance + amount, 96 | PaymentName = name, 97 | Transaction = _state.MaxTransactionId + 1, 98 | TimeUtc = utc 99 | }); 100 | } 101 | 102 | public void Charge(string name, CurrencyAmount amount, DateTime time) 103 | { 104 | if (_state.Currency == Currency.None) 105 | throw new InvalidOperationException("Customer currency was not assigned!"); 106 | Apply(new CustomerChargeAdded() 107 | { 108 | Id = _state.Id, 109 | Charge = amount, 110 | NewBalance = _state.Balance - amount, 111 | ChargeName = name, 112 | Transaction = _state.MaxTransactionId + 1, 113 | TimeUtc = time 114 | }); 115 | } 116 | } 117 | } -------------------------------------------------------------------------------- /Sample/Domain/CustomerAggregate/CustomerApplicationService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Sample.Storage; 3 | 4 | namespace Sample.Domain 5 | { 6 | /// 7 | /// This is an application service within the current bounded context. 8 | /// THis specific application service contains command handlers which load 9 | /// and operate a Customer aggregate. These handlers also pass required 10 | /// dependencies to aggregate methods and perform conflict resolution 11 | /// 12 | /// Command handlers are usually invoked by an infrastructure of an application 13 | /// server, which hosts current service. Infrastructure will be responsible 14 | /// for accepting message calls (in form of web service calls or serialized 15 | /// command messages) and dispatching them to these handlers. 16 | /// 17 | public sealed class CustomerApplicationService : IApplicationService 18 | { 19 | // event store for accessing event streams 20 | readonly IEventStore _eventStore; 21 | // domain service that is neeeded by aggregate 22 | readonly IPricingService _pricingService; 23 | 24 | // pass dependencies for this application service via constructor 25 | public CustomerApplicationService(IEventStore eventStore, IPricingService pricingService) 26 | { 27 | _eventStore = eventStore; 28 | _pricingService = pricingService; 29 | } 30 | 31 | 32 | public void When(CreateCustomer c) 33 | { 34 | Update(c.Id, a => a.Create(c.Id,c.Name, c.Currency, _pricingService, DateTime.UtcNow)); 35 | } 36 | 37 | public void When(RenameCustomer c) 38 | { 39 | Update(c.Id, a=> a.Rename(c.NewName, DateTime.UtcNow)); 40 | } 41 | 42 | public void When(AddCustomerPayment c) 43 | { 44 | Update(c.Id, a => a.AddPayment(c.Name, c.Amount, DateTime.UtcNow)); 45 | } 46 | 47 | public void When(ChargeCustomer c) 48 | { 49 | Update(c.Id, a => a.Charge(c.Name, c.Amount, DateTime.UtcNow)); 50 | } 51 | 52 | public void When(LockCustomerForAccountOverdraft c) 53 | { 54 | Update(c.Id, a => a.LockForAccountOverdraft(c.Comment, _pricingService)); 55 | } 56 | 57 | public void When(LockCustomer c) 58 | { 59 | Update(c.Id, a => a.LockCustomer(c.Reason)); 60 | } 61 | 62 | 63 | // method with direct call, as illustrated in the IDDD Book 64 | 65 | // Step 1: LockCustomerForAccountOverdraft method of 66 | 67 | // Customer Application Service is called 68 | 69 | public void LockCustomerForAccountOverdraft(CustomerId customerId, string comment) 70 | { 71 | // Step 2.1: Load event stream for Customer, given its id 72 | var stream = _eventStore.LoadEventStream(customerId); 73 | // Step 2.2: Build aggregate from event stream 74 | var customer = new Customer(stream.Events); 75 | // Step 3: call aggregate method, passing it arguments and pricing domain service 76 | customer.LockForAccountOverdraft(comment, _pricingService); 77 | // Step 4: commit changes to the event stream by id 78 | _eventStore.AppendToStream(customerId, stream.Version, customer.Changes); 79 | } 80 | 81 | public void Execute(ICommand cmd) 82 | { 83 | // pass command to a specific method named when 84 | // that can handle the command 85 | ((dynamic)this).When((dynamic)cmd); 86 | } 87 | 88 | void Update(CustomerId id, Action execute) 89 | { 90 | // Load event stream from the store 91 | EventStream stream = _eventStore.LoadEventStream(id); 92 | // create new Customer aggregate from the history 93 | Customer customer = new Customer(stream.Events); 94 | // execute delegated action 95 | execute(customer); 96 | // append resulting changes to the stream 97 | _eventStore.AppendToStream(id, stream.Version, customer.Changes); 98 | } 99 | // Sample of method that would apply simple conflict resolution. 100 | // see IDDD book or Greg Young's videos for more in-depth explanation 101 | void UpdateWithSimpleConflictResolution(CustomerId id, Action execute) 102 | { 103 | while (true) 104 | { 105 | EventStream eventStream = _eventStore.LoadEventStream(id); 106 | Customer customer = new Customer(eventStream.Events); 107 | execute(customer); 108 | 109 | try 110 | { 111 | _eventStore.AppendToStream(id, eventStream.Version, customer.Changes); 112 | return; 113 | } 114 | catch (OptimisticConcurrencyException ex) 115 | { 116 | foreach (var clientEvent in customer.Changes) 117 | { 118 | foreach (var actualEvent in ex.ActualEvents) 119 | { 120 | if (ConflictsWith(clientEvent, actualEvent)) 121 | { 122 | var msg = string.Format("Conflict between {0} and {1}", 123 | clientEvent, actualEvent); 124 | throw new RealConcurrencyException(msg, ex); 125 | } 126 | } 127 | } 128 | // there are no conflicts and we can append 129 | _eventStore.AppendToStream(id, ex.ActualVersion, customer.Changes); 130 | } 131 | } 132 | } 133 | 134 | static bool ConflictsWith(IEvent x, IEvent y) 135 | { 136 | return x.GetType() == y.GetType(); 137 | } 138 | } 139 | } -------------------------------------------------------------------------------- /Sample/Domain/CustomerAggregate/CustomerState.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace Sample.Domain 4 | { 5 | /// 6 | /// This is the state of the customer aggregate. 7 | /// It can be mutated only by passing events to it. 8 | /// 9 | public class CustomerState 10 | { 11 | public string Name { get; private set; } 12 | public bool Created { get; private set; } 13 | public CustomerId Id { get; private set; } 14 | public bool ConsumptionLocked { get; private set; } 15 | public bool ManualBilling { get; private set; } 16 | public Currency Currency { get; private set; } 17 | public CurrencyAmount Balance { get; private set; } 18 | 19 | public int MaxTransactionId { get; private set; } 20 | 21 | public CustomerState(IEnumerable events) 22 | { 23 | foreach (var e in events) 24 | { 25 | Mutate(e); 26 | } 27 | } 28 | 29 | public void When(CustomerLocked e) 30 | { 31 | ConsumptionLocked = true; 32 | } 33 | 34 | public void When(CustomerPaymentAdded e) 35 | { 36 | Balance = e.NewBalance; 37 | MaxTransactionId = e.Transaction; 38 | } 39 | public void When(CustomerChargeAdded e) 40 | { 41 | Balance = e.NewBalance; 42 | MaxTransactionId = e.Transaction; 43 | } 44 | 45 | public void When(CustomerCreated e) 46 | { 47 | Created = true; 48 | Name = e.Name; 49 | Id = e.Id; 50 | Currency = e.Currency; 51 | Balance = new CurrencyAmount(0, e.Currency); 52 | } 53 | 54 | public void When(CustomerRenamed e) 55 | { 56 | Name = e.Name; 57 | } 58 | 59 | public void Mutate(IEvent e) 60 | { 61 | // .NET magic to call one of the 'When' handlers with 62 | // matching signature 63 | ((dynamic) this).When((dynamic)e); 64 | } 65 | } 66 | } -------------------------------------------------------------------------------- /Sample/Domain/IPricingService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Sample.Domain 4 | { 5 | /// 6 | /// 7 | /// This is a sample of domain service, that will be injected by application service 8 | /// into aggregate for providing this specific behavior as . 9 | /// 10 | /// 11 | /// During tests, this service will be replaced by test implementation of the same 12 | /// interface (no, you don't need mocking framework, just see the unit tests project). 13 | /// 14 | /// 15 | public interface IPricingService 16 | { 17 | CurrencyAmount GetOverdraftThreshold(Currency currency); 18 | CurrencyAmount GetWelcomeBonus(Currency currency); 19 | } 20 | 21 | /// 22 | /// This is a sample implementation of a Domain Service for pricing. 23 | /// Such services can be more complex than that (i.e.: providing access to payment 24 | /// gateways, cloud fabrics, remote catalogues, expert systems or other 3rd party 25 | /// services). Things that involve such complex computations or remote calls can 26 | /// timeout, fail or blow up. If this is expected and possible, then we can build-in 27 | /// compensation logic for that. 28 | /// 29 | /// The simplest option is to put such compensation logic within the application 30 | /// service itself (usually inside an aggregate hosted by such app service), 31 | /// wrapping actual service call inside WaitFor (google "WaitFor Lokad github") and 32 | /// various retry policies, while catching exceptions and publishing appropriate events. 33 | /// 34 | /// 35 | /// Check out sample of LokadRequest (from .NET client for our forecasting API) 36 | /// for a sample of retries 37 | /// https://github.com/Lokad/lokad-sdk/blob/master/dot-net-rest/Source/Lokad.Forecasting.Client/LokadRequest.cs 38 | /// 39 | /// 40 | /// However this approach can complicate aggregate code by unnecessary tech details. 41 | /// In this case we can push integration details into a separate bounded context. This BC 42 | /// will simply ensure that, whenever a command is received (e.g. "ChargeCreditCard") 43 | /// either "CreditCardCharged" event is published or "CreditCardChargeFailed" shows up 44 | /// within 5 minutes (timeouts are also handled). This also works for big-data processing 45 | /// scenarios, where actual data manipulation is performed by a separate bounded context. 46 | /// 47 | /// 48 | /// This approach (of explicitly modeling integration) is worth it, when integration failures 49 | /// are both frequent and important for your domain (e.g. you charge your customers with 50 | /// the help from 3rd party gateway). 51 | /// 52 | /// 53 | /// Such separate bounded context can use remote tracker to keep an eye on the timeouts 54 | /// and actually publish failure events if nothing happened for too long. 55 | /// http://abdullin.com/journal/2012/4/21/ddd-evolving-business-processes-a-la-lokad.html. 56 | /// 57 | /// 58 | /// Unfortunately, this topic is a bit too big for the A+ES sample for IDDD book. 59 | /// However I will try to address it within Lokad.CQRS sample project, while adding 60 | /// rich domain model. 61 | /// 62 | /// This is written by Rinat Abdullin on 2012-07-19 at Pulkovo. If you are reading this 63 | /// after more than 2 months since that date, and Lokad.CQRS project still does not address 64 | /// this issue, please kick me in the twitter or email. 65 | /// 66 | public sealed class PricingService : IPricingService 67 | { 68 | public CurrencyAmount GetOverdraftThreshold(Currency currency) 69 | { 70 | if (currency == Currency.Eur) 71 | return (-10m).Eur(); 72 | throw new NotImplementedException("TODO: implement other currencies"); 73 | } 74 | 75 | public CurrencyAmount GetWelcomeBonus(Currency currency) 76 | { 77 | if (currency == Currency.Eur) 78 | return 15m.Eur(); 79 | throw new NotImplementedException("TODO: implement other currencies"); 80 | } 81 | } 82 | 83 | } -------------------------------------------------------------------------------- /Sample/Domain/ReadMe.md: -------------------------------------------------------------------------------- 1 | > Rinat Abdullin, 2012-07-15 2 | 3 | This folder contains domain of this IDDD Sample. In DDD+ES projects this 4 | usually is the most important part of the code. This is where core business 5 | concepts are captured! 6 | 7 | Code in this folder is explained in great detail in A+ES chapter of book on 8 | Intelligent Domain-Driven Design by Vaughn Vernon. 9 | 10 | Everything else (like infrastructure and storage implementations) is completely 11 | disposable. Normally, you should be able to take your domain code and swap 12 | infrastructure detail with relative use (e.g. switching from local dev machine 13 | to Windows Azure cloud). At least, this is how we develop things at Lokad. -------------------------------------------------------------------------------- /Sample/Interfaces.cs: -------------------------------------------------------------------------------- 1 | namespace Sample 2 | { 3 | /// 4 | /// Interface for the application service, which can handle multiple commands. 5 | /// 6 | /// Application server will host multiple application services, passing commands to them 7 | /// via this interface. Additional cross-cutting concerns can be wrapped around as necessary 8 | /// () 9 | /// This is only one option of wiring things together. 10 | /// 11 | public interface IApplicationService 12 | { 13 | void Execute(ICommand cmd); 14 | } 15 | /// 16 | /// Interface, which marks our events to provide some strong-typing. 17 | /// In real-world systems we can have more fine-grained interfaces 18 | /// 19 | public interface IEvent { } 20 | 21 | /// 22 | /// Interface for commands, which we send to the application server. 23 | /// In real-world systems we can have more fine-grained interfaces 24 | /// 25 | public interface ICommand { } 26 | 27 | /// 28 | /// Base class for all identities. It might not seem that useful in this sample, 29 | /// however becomes really useful in the projects, where you have dozens of aggregate 30 | /// types mixed with stateless (functional) services 31 | /// 32 | public interface IIdentity { } 33 | } -------------------------------------------------------------------------------- /Sample/LoggingWrapper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | 4 | namespace Sample 5 | { 6 | /// 7 | /// Demonstrates how to add logging aspect to any application service 8 | /// 9 | public class LoggingWrapper : IApplicationService 10 | { 11 | readonly IApplicationService _service; 12 | public LoggingWrapper(IApplicationService service) 13 | { 14 | _service = service; 15 | } 16 | 17 | static void WriteLine(ConsoleColor color, string text, params object[] args) 18 | { 19 | var oldColor = Console.ForegroundColor; 20 | Console.ForegroundColor = color; 21 | Console.WriteLine(text, args); 22 | Console.ForegroundColor = oldColor; 23 | } 24 | 25 | public void Execute(ICommand cmd) 26 | { 27 | WriteLine(ConsoleColor.DarkCyan, "Command: " + cmd); 28 | try 29 | { 30 | var watch = Stopwatch.StartNew(); 31 | _service.Execute(cmd); 32 | var ms = watch.ElapsedMilliseconds; 33 | WriteLine(ConsoleColor.DarkCyan, " Completed in {0} ms", ms); 34 | } 35 | catch( Exception ex) 36 | { 37 | WriteLine(ConsoleColor.DarkRed, "Error: {0}", ex); 38 | } 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /Sample/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using Sample.Domain; 5 | using Sample.Storage; 6 | using Sample.Storage.Files; 7 | using Sample.Storage.MsSql; 8 | 9 | namespace Sample 10 | { 11 | public static class Program 12 | { 13 | public static void Main() 14 | { 15 | if (File.Exists("Readme.md")) 16 | Console.WriteLine(File.ReadAllText("Readme.md")); 17 | 18 | // persistence 19 | var store = CreateFileStoreForTesting(); 20 | var events = new EventStore(store); 21 | 22 | // various domain services 23 | var pricing = new PricingService(); 24 | 25 | var server = new ApplicationServer(); 26 | server.Handlers.Add(new LoggingWrapper(new CustomerApplicationService(events, pricing))); 27 | 28 | // send some sample commands 29 | server.Dispatch(new CreateCustomer {Id = new CustomerId(12), Name = "Lokad", Currency = Currency.Eur}); 30 | server.Dispatch(new RenameCustomer {Id = new CustomerId(12), NewName = "Lokad SAS"}); 31 | server.Dispatch(new ChargeCustomer {Id = new CustomerId(12), Amount = 20m.Eur(), Name = "Forecasting"}); 32 | 33 | Console.WriteLine("Press any key to exit"); 34 | Console.ReadKey(true); 35 | } 36 | 37 | static IAppendOnlyStore CreateSqlStore() 38 | { 39 | var conn = "Data Source=.\\SQLExpress;Initial Catalog=lokadsalescast_samples;Integrated Security=true"; 40 | var store = new SqlAppendOnlyStore(conn); 41 | store.Initialize(); 42 | return store; 43 | } 44 | 45 | static IAppendOnlyStore CreateFileStoreForTesting() 46 | { 47 | var combine = Path.Combine(Directory.GetCurrentDirectory(), "store"); 48 | if (Directory.Exists(combine)) 49 | { 50 | Console.WriteLine(); 51 | Console.WriteLine("Wiping file event store for demo purposes."); 52 | Console.WriteLine("You can switch to Azure or SQL event stores by modifying Program.cs"); 53 | Console.WriteLine(); 54 | Directory.Delete(combine, true); 55 | } 56 | var store = new FileAppendOnlyStore(combine); 57 | store.Initialize(); 58 | return store; 59 | } 60 | 61 | /// 62 | /// This is a simplified representation of real application server. 63 | /// In production it is wired to messaging and/or services infrastructure. 64 | public sealed class ApplicationServer 65 | { 66 | public void Dispatch(ICommand cmd) 67 | { 68 | foreach (var handler in Handlers) 69 | { 70 | handler.Execute(cmd); 71 | } 72 | } 73 | 74 | public readonly IList Handlers = new List(); 75 | } 76 | } 77 | } -------------------------------------------------------------------------------- /Sample/Projections/CustomerListProjection.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Sample.Domain; 4 | 5 | namespace Sample.Projections 6 | { 7 | /// 8 | /// NB: this class is currently not wired to the infrastructure. 9 | /// See Lokad.CQRS Sample project for more details 10 | /// 11 | public class CustomerTransactionsProjection 12 | { 13 | readonly IDocumentWriter _store; 14 | public CustomerTransactionsProjection(IDocumentWriter store) 15 | { 16 | _store = store; 17 | } 18 | public void When(CustomerCreated e) 19 | { 20 | _store.Add(e.Id, new CustomerTransactions()); 21 | } 22 | public void When(CustomerChargeAdded e) 23 | { 24 | _store.UpdateOrThrow(e.Id, v => v.AddTx(e.ChargeName, -e.Charge, e.NewBalance, e.TimeUtc)); 25 | } 26 | public void When(CustomerPaymentAdded e) 27 | { 28 | _store.UpdateOrThrow(e.Id, v => v.AddTx(e.PaymentName, e.Payment, e.NewBalance, e.TimeUtc)); 29 | } 30 | } 31 | [Serializable] 32 | public class CustomerTransactions 33 | { 34 | public IList Transactions = new List(); 35 | public void AddTx(string name, CurrencyAmount change, CurrencyAmount balance, DateTime timeUtc) 36 | { 37 | Transactions.Add(new CustomerTransaction() 38 | { 39 | Name = name, 40 | Balance = balance, 41 | Change = change, 42 | TimeUtc = timeUtc 43 | }); 44 | } 45 | } 46 | [Serializable] 47 | public class CustomerTransaction 48 | { 49 | public CurrencyAmount Change; 50 | public CurrencyAmount Balance; 51 | public string Name; 52 | public DateTime TimeUtc; 53 | } 54 | } -------------------------------------------------------------------------------- /Sample/Projections/IDocumentWriter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Sample.Projections 4 | { 5 | /// 6 | /// This is a sample of strongly-typed document writer interface, 7 | /// which works good for building simple systems that can be migrated 8 | /// between cloud and various on-premises implementations. 9 | /// This interface supports automated view rebuilding (which is 10 | /// demonstrated in greater detail in Lokad.CQRS project) 11 | /// 12 | /// The type of the key. 13 | /// The type of the entity. 14 | public interface IDocumentWriter 15 | { 16 | TEntity AddOrUpdate(TKey key, Func addFactory, Func update, AddOrUpdateHint hint = AddOrUpdateHint.ProbablyExists); 17 | bool TryDelete(TKey key); 18 | } 19 | public enum AddOrUpdateHint 20 | { 21 | ProbablyExists, 22 | ProbablyDoesNotExist 23 | } 24 | 25 | public static class ExtendDocumentWriter 26 | { 27 | /// 28 | /// Given a either adds a new OR updates an existing one. 29 | /// 30 | /// The type of the key. 31 | /// The type of the entity. 32 | /// The self. 33 | /// The key. 34 | /// The add factory (used to create a new entity, if it is not found). 35 | /// The update method (called to update an existing entity, if it exists). 36 | /// The hint. 37 | /// 38 | public static TEntity AddOrUpdate(this IDocumentWriter self, TKey key, Func addFactory, Action update, AddOrUpdateHint hint = AddOrUpdateHint.ProbablyExists) 39 | { 40 | return self.AddOrUpdate(key, addFactory, entity => 41 | { 42 | update(entity); 43 | return entity; 44 | }, hint); 45 | } 46 | /// 47 | /// Given a either adds a new OR updates an existing one. 48 | /// 49 | /// The type of the key. 50 | /// The type of the entity. 51 | /// The self. 52 | /// The key. 53 | /// The new view that will be saved, if entity does not already exist 54 | /// The update method (called to update an existing entity, if it exists). 55 | /// The hint. 56 | /// 57 | public static TEntity AddOrUpdate(this IDocumentWriter self, TKey key, TEntity newView, Action updateViewFactory, AddOrUpdateHint hint = AddOrUpdateHint.ProbablyExists) 58 | { 59 | return self.AddOrUpdate(key, () => newView, view => 60 | { 61 | updateViewFactory(view); 62 | return view; 63 | }, hint); 64 | } 65 | 66 | /// 67 | /// Saves new entity, using the provided and throws 68 | /// if the entity actually already exists 69 | /// 70 | /// The type of the key. 71 | /// The type of the entity. 72 | /// The self. 73 | /// The key. 74 | /// The new entity. 75 | /// 76 | public static TEntity Add(this IDocumentWriter self, TKey key, TEntity newEntity) 77 | { 78 | return self.AddOrUpdate(key, newEntity, e => 79 | { 80 | var txt = String.Format("Entity '{0}' with key '{1}' should not exist.", typeof(TEntity).Name, key); 81 | throw new InvalidOperationException(txt); 82 | }, AddOrUpdateHint.ProbablyDoesNotExist); 83 | } 84 | 85 | 86 | /// 87 | /// Updates already existing entity, throwing exception, if it does not already exist. 88 | /// 89 | /// The type of the key. 90 | /// The type of the entity. 91 | /// The self. 92 | /// The key. 93 | /// The change. 94 | /// 95 | public static TEntity UpdateOrThrow(this IDocumentWriter self, TKey key, Func change) 96 | { 97 | return self.AddOrUpdate(key, () => 98 | { 99 | var txt = String.Format("Failed to load '{0}' with key '{1}'.", typeof(TEntity).Name, key); 100 | throw new InvalidOperationException(txt); 101 | }, change, AddOrUpdateHint.ProbablyExists); 102 | } 103 | /// 104 | /// Updates already existing entity, throwing exception, if it does not already exist. 105 | /// 106 | /// The type of the key. 107 | /// The type of the entity. 108 | /// The self. 109 | /// The key. 110 | /// The change. 111 | /// 112 | public static TEntity UpdateOrThrow(this IDocumentWriter self, TKey key, Action change) 113 | { 114 | return self.AddOrUpdate(key, () => 115 | { 116 | var txt = String.Format("Failed to load '{0}' with key '{1}'.", typeof(TEntity).Name, key); 117 | throw new InvalidOperationException(txt); 118 | }, change, AddOrUpdateHint.ProbablyExists); 119 | } 120 | 121 | /// 122 | /// Updates an entity, creating a new instance before that, if needed. 123 | /// 124 | /// The type of the key. 125 | /// The type of the view. 126 | /// The self. 127 | /// The key. 128 | /// The update. 129 | /// The hint. 130 | /// 131 | public static TView UpdateEnforcingNew(this IDocumentWriter self, TKey key, 132 | Action update, AddOrUpdateHint hint = AddOrUpdateHint.ProbablyExists) 133 | where TView : new() 134 | { 135 | return self.AddOrUpdate(key, () => 136 | { 137 | var view = new TView(); 138 | update(view); 139 | return view; 140 | }, v => 141 | { 142 | update(v); 143 | return v; 144 | }, hint); 145 | } 146 | } 147 | } -------------------------------------------------------------------------------- /Sample/Projections/ReadMe.md: -------------------------------------------------------------------------------- 1 | > Rinat Abdullin, 2012-07-15 2 | 3 | This is a set of abstractions that demonstrate, how we can wire 4 | view projections that denormalize events into persistent read models (views). 5 | 6 | For deeper detail in this area, visit Lokad.CQRS project, which features 7 | multuple document store implementations (places, where views are persisted) and 8 | also automatic projection management code (which automatically rebuilds 9 | views, if projection code has changed). -------------------------------------------------------------------------------- /Sample/ReadMe.md: -------------------------------------------------------------------------------- 1 | This "RunMe.exe" project contains sample framework, infrastructure and 2 | actual domain implementation for Lokad-IDDD-Sample, which focuses solely 3 | on A+ES concepts (Aggregates implemented with Event Sourcing) as described 4 | in "Implementing Domain-Driven Design" by Vaughn Vernon. 5 | 6 | Please see README.Markdown, in the upper folder, if you need high-level overview. 7 | 8 | You can run this project by starting in in Visual Studio (it is a console) 9 | 10 | There are following folders: 11 | 12 | * Domain - actual aggregates which capture core business concepts 13 | * Projection - sample source code for event handlers which "project" events 14 | into persistent read models called views. 15 | * Storage - interfaces for event store and actual implementations for various 16 | persistence options. 17 | 18 | Plus, there also is UnitTests project next to this one. 19 | -------------------------------------------------------------------------------- /Sample/RunMe.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Debug 5 | AnyCPU 6 | 8.0.30703 7 | 2.0 8 | {D79777B6-AFD9-4CCE-824B-98C704C00AEE} 9 | Exe 10 | Properties 11 | Sample 12 | Sample 13 | v4.0 14 | 512 15 | 16 | 17 | true 18 | full 19 | false 20 | bin\Debug\ 21 | DEBUG;TRACE 22 | prompt 23 | 4 24 | 25 | 26 | pdbonly 27 | true 28 | bin\Release\ 29 | TRACE 30 | prompt 31 | 4 32 | 33 | 34 | 35 | 36 | 37 | 38 | False 39 | ..\Library\Azure\Microsoft.WindowsAzure.StorageClient.dll 40 | 41 | 42 | ..\Library\mysql\mysql.data.dll 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | Always 76 | 77 | 78 | 79 | 80 | 87 | -------------------------------------------------------------------------------- /Sample/Storage/AppendOnlyStream.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | 4 | namespace Sample.Storage 5 | { 6 | /// 7 | /// Helps to write data to the underlying store, which accepts only 8 | /// pages with specific size 9 | /// 10 | public sealed class AppendOnlyStream : IDisposable 11 | { 12 | readonly int _pageSizeInBytes; 13 | readonly AppendWriterDelegate _writer; 14 | readonly int _maxByteCount; 15 | MemoryStream _pending; 16 | 17 | int _bytesWritten; 18 | int _bytesPending; 19 | int _fullPagesFlushed; 20 | 21 | public AppendOnlyStream(int pageSizeInBytes, AppendWriterDelegate writer, int maxByteCount) 22 | { 23 | _writer = writer; 24 | _maxByteCount = maxByteCount; 25 | _pageSizeInBytes = pageSizeInBytes; 26 | _pending = new MemoryStream(); 27 | } 28 | 29 | public bool Fits(int byteCount) 30 | { 31 | return (_bytesWritten + byteCount <= _maxByteCount); 32 | } 33 | 34 | public void Write(byte[] buffer) 35 | { 36 | _pending.Write(buffer, 0, buffer.Length); 37 | _bytesWritten += buffer.Length; 38 | _bytesPending += buffer.Length; 39 | } 40 | 41 | public void Flush() 42 | { 43 | if (_bytesPending == 0) 44 | return; 45 | 46 | var size = (int)_pending.Length; 47 | var padSize = (_pageSizeInBytes - size % _pageSizeInBytes) % _pageSizeInBytes; 48 | 49 | using (var stream = new MemoryStream(size + padSize)) 50 | { 51 | stream.Write(_pending.ToArray(), 0, (int)_pending.Length); 52 | if (padSize > 0) 53 | stream.Write(new byte[padSize], 0, padSize); 54 | 55 | stream.Position = 0; 56 | _writer(_fullPagesFlushed * _pageSizeInBytes, stream); 57 | } 58 | 59 | var fullPagesFlushed = size / _pageSizeInBytes; 60 | 61 | if (fullPagesFlushed <= 0) 62 | return; 63 | 64 | // Copy remainder to the new stream and dispose the old stream 65 | var newStream = new MemoryStream(); 66 | _pending.Position = fullPagesFlushed * _pageSizeInBytes; 67 | _pending.CopyTo(newStream); 68 | _pending.Dispose(); 69 | _pending = newStream; 70 | 71 | _fullPagesFlushed += fullPagesFlushed; 72 | _bytesPending = 0; 73 | } 74 | 75 | public void Dispose() 76 | { 77 | Flush(); 78 | _pending.Dispose(); 79 | } 80 | } 81 | 82 | /// 83 | /// Delegate that writes pages to the underlying paged store. 84 | /// 85 | /// The offset. 86 | /// The source. 87 | public delegate void AppendWriterDelegate(int offset, Stream source); 88 | } -------------------------------------------------------------------------------- /Sample/Storage/Azure/AutoRenewLease.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.Net; 4 | using System.Threading; 5 | using Microsoft.WindowsAzure.StorageClient; 6 | using Microsoft.WindowsAzure.StorageClient.Protocol; 7 | 8 | namespace Sample.Storage.Azure 9 | { 10 | /// 11 | /// Helper class that keeps renewing ownership lease (lock) of a specific blob, 12 | /// while the process is alive 13 | /// 14 | public class AutoRenewLease : IDisposable 15 | { 16 | readonly CloudBlob _blob; 17 | readonly string _leaseId; 18 | bool _disposed; 19 | Thread _renewalThread; 20 | readonly CancellationTokenSource _cancelSource = new CancellationTokenSource(); 21 | 22 | AutoRenewLease(CloudBlob blob, string leaseId) 23 | { 24 | _blob = blob; 25 | _leaseId = leaseId; 26 | 27 | _renewalThread = new Thread(() => 28 | { 29 | var token = _cancelSource.Token; 30 | token.WaitHandle.WaitOne(TimeSpan.FromSeconds(40)); 31 | while (!token.IsCancellationRequested) 32 | { 33 | try 34 | { 35 | var e = DoUntilTrue(Try4Times, CancellationToken.None, 36 | () => RenewLease(blob, _leaseId) || token.IsCancellationRequested); 37 | 38 | if (e != null) 39 | { 40 | Exception = e; 41 | break; 42 | } 43 | 44 | var sw = Stopwatch.StartNew(); 45 | while (sw.Elapsed.TotalSeconds < 40 && !token.IsCancellationRequested) 46 | { 47 | token.WaitHandle.WaitOne(100); 48 | } 49 | } 50 | catch (Exception e) 51 | { 52 | Exception = e; 53 | break; 54 | } 55 | } 56 | }); 57 | _renewalThread.Start(); 58 | } 59 | 60 | public Exception Exception { get; private set; } 61 | 62 | public static AutoRenewLease GetOrThrow(CloudBlob blob) 63 | { 64 | blob.Container.CreateIfNotExist(); 65 | 66 | // Create lock blob 67 | try 68 | { 69 | var requestOptions = new BlobRequestOptions 70 | { 71 | AccessCondition = AccessCondition.IfNoneMatch("*") 72 | }; 73 | blob.UploadByteArray(new byte[0], requestOptions); 74 | } 75 | catch (StorageClientException e) 76 | { 77 | if (e.ErrorCode != StorageErrorCode.BlobAlreadyExists 78 | && e.StatusCode != HttpStatusCode.PreconditionFailed) 79 | // 412 from trying to modify a blob that's leased 80 | { 81 | throw; 82 | } 83 | } 84 | 85 | string leaseId = null; 86 | var ex = DoUntilTrue(Try4Times, CancellationToken.None, () => 87 | { 88 | leaseId = AcquireLease(blob); 89 | return !String.IsNullOrEmpty(leaseId); 90 | }); 91 | if (ex != null) 92 | throw new InvalidOperationException("Failed to get lease", ex); 93 | 94 | // Either we get lease or throw timeout exception 95 | if (String.IsNullOrEmpty(leaseId)) 96 | throw new InvalidOperationException(); 97 | 98 | return new AutoRenewLease(blob, leaseId); 99 | } 100 | 101 | public void Dispose() 102 | { 103 | if (_disposed) 104 | return; 105 | 106 | if (_renewalThread != null) 107 | { 108 | _cancelSource.Cancel(); 109 | 110 | DoUntilTrue(Try4Times, CancellationToken.None, 111 | () => ReleaseLease(_blob, _leaseId)); 112 | _renewalThread = null; 113 | } 114 | _disposed = true; 115 | } 116 | 117 | static string AcquireLease(CloudBlob blob) 118 | { 119 | var creds = blob.ServiceClient.Credentials; 120 | var transformedUri = new Uri(creds.TransformUri(blob.Uri.AbsoluteUri)); 121 | var req = BlobRequest.Lease(transformedUri, 10, LeaseAction.Acquire, null); 122 | req.Headers.Add("x-ms-lease-duration", "60"); 123 | creds.SignRequest(req); 124 | 125 | HttpWebResponse response; 126 | try 127 | { 128 | response = (HttpWebResponse)req.GetResponse(); 129 | } 130 | catch (WebException we) 131 | { 132 | var statusCode = ((HttpWebResponse)we.Response).StatusCode; 133 | switch (statusCode) 134 | { 135 | case HttpStatusCode.Conflict: 136 | case HttpStatusCode.NotFound: 137 | case HttpStatusCode.RequestTimeout: 138 | case HttpStatusCode.InternalServerError: 139 | return null; 140 | default: 141 | throw; 142 | } 143 | } 144 | 145 | try 146 | { 147 | return response.StatusCode == HttpStatusCode.Created 148 | ? response.Headers["x-ms-lease-id"] 149 | : null; 150 | } 151 | finally 152 | { 153 | response.Close(); 154 | } 155 | } 156 | 157 | static bool DoLeaseOperation(CloudBlob blob, string leaseId, LeaseAction action) 158 | { 159 | var creds = blob.ServiceClient.Credentials; 160 | var transformedUri = new Uri(creds.TransformUri(blob.Uri.ToString())); 161 | var req = BlobRequest.Lease(transformedUri, 10, action, leaseId); 162 | creds.SignRequest(req); 163 | 164 | HttpWebResponse response; 165 | try 166 | { 167 | response = (HttpWebResponse)req.GetResponse(); 168 | } 169 | catch (WebException we) 170 | { 171 | var statusCode = ((HttpWebResponse)we.Response).StatusCode; 172 | switch (statusCode) 173 | { 174 | case HttpStatusCode.Conflict: 175 | case HttpStatusCode.NotFound: 176 | case HttpStatusCode.RequestTimeout: 177 | case HttpStatusCode.InternalServerError: 178 | return false; 179 | default: 180 | throw; 181 | } 182 | } 183 | 184 | try 185 | { 186 | var expectedCode = action == LeaseAction.Break ? HttpStatusCode.Accepted : HttpStatusCode.OK; 187 | return response.StatusCode == expectedCode; 188 | } 189 | finally 190 | { 191 | response.Close(); 192 | } 193 | } 194 | 195 | static bool ReleaseLease(CloudBlob blob, string leaseId) 196 | { 197 | return DoLeaseOperation(blob, leaseId, LeaseAction.Release); 198 | } 199 | 200 | static bool RenewLease(CloudBlob blob, string leaseId) 201 | { 202 | return DoLeaseOperation(blob, leaseId, LeaseAction.Renew); 203 | } 204 | 205 | /// Policy must support exceptions being null. 206 | static Exception DoUntilTrue(ShouldRetry retryPolicy, CancellationToken token, Func action) 207 | { 208 | var retryCount = 0; 209 | 210 | while (true) 211 | { 212 | token.ThrowIfCancellationRequested(); 213 | try 214 | { 215 | if (action()) 216 | { 217 | return null; 218 | } 219 | 220 | TimeSpan delay; 221 | if (retryPolicy(retryCount, null, out delay)) 222 | { 223 | retryCount++; 224 | if (delay > TimeSpan.Zero) 225 | { 226 | token.WaitHandle.WaitOne(delay); 227 | } 228 | 229 | continue; 230 | } 231 | 232 | return new TimeoutException("Failed to reach a successful result in a limited number of retrials"); 233 | } 234 | catch (Exception e) 235 | { 236 | TimeSpan delay; 237 | if (retryPolicy(retryCount, e, out delay)) 238 | { 239 | retryCount++; 240 | if (delay > TimeSpan.Zero) 241 | { 242 | token.WaitHandle.WaitOne(delay); 243 | } 244 | 245 | continue; 246 | } 247 | 248 | return e; 249 | } 250 | } 251 | } 252 | 253 | /// 254 | /// Retry policy for optimistic concurrency retrials. 255 | /// 256 | static readonly ShouldRetry Try4Times = delegate(int currentRetryCount, Exception lastException, out TimeSpan retryInterval) 257 | { 258 | if (currentRetryCount >= 5) 259 | { 260 | retryInterval = TimeSpan.Zero; 261 | return false; 262 | } 263 | 264 | retryInterval = TimeSpan.FromSeconds(2); 265 | 266 | return true; 267 | }; 268 | } 269 | 270 | } -------------------------------------------------------------------------------- /Sample/Storage/Azure/BlobAppendOnlyStore.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Collections.Generic; 4 | using System.Diagnostics; 5 | using System.IO; 6 | using System.Linq; 7 | using System.Net; 8 | using System.Security.Cryptography; 9 | using System.Text; 10 | using System.Threading; 11 | using Microsoft.WindowsAzure.StorageClient; 12 | 13 | namespace Sample.Storage.Azure 14 | { 15 | /// 16 | /// This is embedded append-only store implemented on top of cloud page blobs 17 | /// (for persisting data with one HTTP call). 18 | /// This store ensures that only one writer exists and writes to a given event store 19 | /// This code is frozen to match IDDD book. For latest practices see Lokad.CQRS Project 20 | /// 21 | public sealed class BlobAppendOnlyStore : IAppendOnlyStore 22 | { 23 | readonly CloudBlobContainer _container; 24 | 25 | // Caches 26 | readonly ConcurrentDictionary _items = new ConcurrentDictionary(); 27 | DataWithName[] _all = new DataWithName[0]; 28 | 29 | /// 30 | /// Used to synchronize access between multiple threads within one process 31 | /// 32 | readonly ReaderWriterLockSlim _cacheLock = new ReaderWriterLockSlim(); 33 | 34 | 35 | bool _closed; 36 | 37 | /// 38 | /// Currently open file 39 | /// 40 | AppendOnlyStream _currentWriter; 41 | 42 | /// 43 | /// Renewable Blob lease, used to prohibit multiple writers outside a given process 44 | /// 45 | AutoRenewLease _lock; 46 | 47 | public BlobAppendOnlyStore(CloudBlobContainer container) 48 | { 49 | _container = container; 50 | } 51 | 52 | public void Dispose() 53 | { 54 | if (!_closed) 55 | Close(); 56 | } 57 | 58 | public void InitializeWriter() 59 | { 60 | CreateIfNotExists(_container, TimeSpan.FromSeconds(60)); 61 | // grab the ownership 62 | var blobReference = _container.GetBlobReference("lock"); 63 | _lock = AutoRenewLease.GetOrThrow(blobReference); 64 | 65 | LoadCaches(); 66 | } 67 | public void InitializeReader() 68 | { 69 | CreateIfNotExists(_container, TimeSpan.FromSeconds(60)); 70 | LoadCaches(); 71 | } 72 | 73 | public void Append(string streamName, byte[] data, long expectedStreamVersion = -1) 74 | { 75 | 76 | // should be locked 77 | try 78 | { 79 | _cacheLock.EnterWriteLock(); 80 | 81 | var list = _items.GetOrAdd(streamName, s => new DataWithVersion[0]); 82 | if (expectedStreamVersion >= 0) 83 | { 84 | if (list.Length != expectedStreamVersion) 85 | throw new AppendOnlyStoreConcurrencyException(expectedStreamVersion, list.Length, streamName); 86 | } 87 | 88 | EnsureWriterExists(_all.Length); 89 | long commit = list.Length + 1; 90 | 91 | Persist(streamName, data, commit); 92 | AddToCaches(streamName, data, commit); 93 | } 94 | catch 95 | { 96 | Close(); 97 | throw; 98 | } 99 | finally 100 | { 101 | _cacheLock.ExitWriteLock(); 102 | } 103 | } 104 | 105 | public IEnumerable ReadRecords(string streamName, long afterVersion, int maxCount) 106 | { 107 | // no lock is needed, since we are polling immutable object. 108 | DataWithVersion[] list; 109 | return _items.TryGetValue(streamName, out list) ? list : Enumerable.Empty(); 110 | } 111 | 112 | public IEnumerable ReadRecords(long afterVersion, int maxCount) 113 | { 114 | // collection is immutable so we don't care about locks 115 | return _all.Skip((int)afterVersion).Take(maxCount); 116 | } 117 | 118 | public void Close() 119 | { 120 | using (_lock) 121 | { 122 | _closed = true; 123 | 124 | if (_currentWriter == null) 125 | return; 126 | 127 | var tmp = _currentWriter; 128 | _currentWriter = null; 129 | tmp.Dispose(); 130 | } 131 | } 132 | 133 | IEnumerable EnumerateHistory() 134 | { 135 | // cleanup old pending files 136 | // load indexes 137 | // build and save missing indexes 138 | var datFiles = _container 139 | .ListBlobs() 140 | .OrderBy(s => s.Uri.ToString()) 141 | .OfType(); 142 | 143 | foreach (var fileInfo in datFiles) 144 | { 145 | using (var stream = new MemoryStream(fileInfo.DownloadByteArray())) 146 | using (var reader = new BinaryReader(stream, Encoding.UTF8)) 147 | { 148 | Record result; 149 | while (TryReadRecord(reader, out result)) 150 | { 151 | yield return result; 152 | } 153 | } 154 | } 155 | } 156 | 157 | static bool TryReadRecord(BinaryReader binary, out Record result) 158 | { 159 | result = null; 160 | 161 | try 162 | { 163 | var version = binary.ReadInt64(); 164 | var name = binary.ReadString(); 165 | var len = binary.ReadInt32(); 166 | var bytes = binary.ReadBytes(len); 167 | 168 | var sha1 = binary.ReadBytes(20); 169 | if (sha1.All(s => s == 0)) 170 | throw new InvalidOperationException("definitely failed (zero hash)"); 171 | 172 | byte[] actualSha1; 173 | PersistRecord(name, bytes, version, out actualSha1); 174 | 175 | if (!sha1.SequenceEqual(actualSha1)) 176 | throw new InvalidOperationException("hash mismatch"); 177 | 178 | result = new Record(bytes, name, version); 179 | return true; 180 | } 181 | catch (EndOfStreamException) 182 | { 183 | // we are done 184 | return false; 185 | } 186 | catch (Exception ex) 187 | { 188 | Trace.WriteLine(ex); 189 | // Auto-clean? 190 | return false; 191 | } 192 | } 193 | 194 | void LoadCaches() 195 | { 196 | try 197 | { 198 | _cacheLock.EnterWriteLock(); 199 | 200 | foreach (var record in EnumerateHistory()) 201 | { 202 | AddToCaches(record.Name, record.Bytes, record.Version); 203 | } 204 | } 205 | finally 206 | { 207 | _cacheLock.ExitWriteLock(); 208 | } 209 | } 210 | 211 | void AddToCaches(string key, byte[] buffer, long commit) 212 | { 213 | var record = new DataWithVersion(commit, buffer); 214 | _all = AddToNewArray(_all, new DataWithName(key, buffer)); 215 | _items.AddOrUpdate(key, s => new[] { record }, (s, records) => AddToNewArray(records, record)); 216 | } 217 | 218 | static T[] AddToNewArray(T[] source, T item) 219 | { 220 | var copy = new T[source.Length + 1]; 221 | Array.Copy(source, copy, source.Length); 222 | copy[source.Length] = item; 223 | return copy; 224 | } 225 | 226 | void Persist(string key, byte[] buffer, long commit) 227 | { 228 | byte[] hash; 229 | var bytes = PersistRecord(key, buffer, commit, out hash); 230 | 231 | if (!_currentWriter.Fits(bytes.Length + hash.Length)) 232 | { 233 | CloseWriter(); 234 | EnsureWriterExists(_all.Length); 235 | } 236 | 237 | _currentWriter.Write(bytes); 238 | _currentWriter.Write(hash); 239 | _currentWriter.Flush(); 240 | } 241 | 242 | static byte[] PersistRecord(string key, byte[] buffer, long commit, out byte[] hash) 243 | { 244 | using (var sha1 = new SHA1Managed()) 245 | using (var memory = new MemoryStream()) 246 | { 247 | using (var crypto = new CryptoStream(memory, sha1, CryptoStreamMode.Write)) 248 | using (var binary = new BinaryWriter(crypto, Encoding.UTF8)) 249 | { 250 | // version, ksz, vsz, key, value, sha1 251 | binary.Write(commit); 252 | binary.Write(key); 253 | binary.Write(buffer.Length); 254 | binary.Write(buffer); 255 | } 256 | 257 | hash = sha1.Hash; 258 | return memory.ToArray(); 259 | } 260 | } 261 | 262 | void CloseWriter() 263 | { 264 | _currentWriter.Dispose(); 265 | _currentWriter = null; 266 | } 267 | 268 | void EnsureWriterExists(long version) 269 | { 270 | 271 | 272 | if (_lock.Exception != null) 273 | throw new InvalidOperationException("Can not renew lease", _lock.Exception); 274 | 275 | if (_currentWriter != null) 276 | return; 277 | 278 | var fileName = string.Format("{0:00000000}-{1:yyyy-MM-dd-HHmm}.dat", version, DateTime.UtcNow); 279 | var blob = _container.GetPageBlobReference(fileName); 280 | blob.Create(1024 * 512); 281 | 282 | _currentWriter = new AppendOnlyStream(512, (i, bytes) => blob.WritePages(bytes, i), 1024 * 512); 283 | } 284 | 285 | static void CreateIfNotExists(CloudBlobContainer container, TimeSpan timeout) 286 | { 287 | var sw = Stopwatch.StartNew(); 288 | while (sw.Elapsed < timeout) 289 | { 290 | try 291 | { 292 | container.CreateIfNotExist(); 293 | return; 294 | } 295 | catch (StorageClientException e) 296 | { 297 | // container is being deleted 298 | if (!(e.ErrorCode == StorageErrorCode.ResourceAlreadyExists && e.StatusCode == HttpStatusCode.Conflict)) 299 | throw; 300 | } 301 | Thread.Sleep(500); 302 | } 303 | 304 | throw new TimeoutException(string.Format("Can not create container within {0} seconds.", timeout.TotalSeconds)); 305 | } 306 | 307 | sealed class Record 308 | { 309 | public readonly byte[] Bytes; 310 | public readonly string Name; 311 | public readonly long Version; 312 | 313 | public Record(byte[] bytes, string name, long version) 314 | { 315 | Bytes = bytes; 316 | Name = name; 317 | Version = version; 318 | } 319 | } 320 | } 321 | } -------------------------------------------------------------------------------- /Sample/Storage/EventStore.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Runtime.Serialization.Formatters.Binary; 6 | 7 | namespace Sample.Storage 8 | { 9 | /// 10 | /// Actual implementation of , which deals with 11 | /// serialization and naming in order to provide bridge between event-centric 12 | /// domain code and byte-based append-only persistence 13 | /// 14 | public class EventStore : IEventStore 15 | { 16 | readonly BinaryFormatter _formatter = new BinaryFormatter(); 17 | 18 | byte[] SerializeEvent(IEvent[] e) 19 | { 20 | using (var mem = new MemoryStream()) 21 | { 22 | _formatter.Serialize(mem, e); 23 | return mem.ToArray(); 24 | } 25 | } 26 | 27 | IEvent[] DeserializeEvent(byte[] data) 28 | { 29 | using (var mem = new MemoryStream(data)) 30 | { 31 | return (IEvent[])_formatter.Deserialize(mem); 32 | } 33 | } 34 | 35 | public EventStore(IAppendOnlyStore appendOnlyStore) 36 | { 37 | _appendOnlyStore = appendOnlyStore; 38 | } 39 | 40 | readonly IAppendOnlyStore _appendOnlyStore; 41 | public EventStream LoadEventStream(IIdentity id, long skip, int take) 42 | { 43 | var name = IdentityToString(id); 44 | var records = _appendOnlyStore.ReadRecords(name, skip, take).ToList(); 45 | var stream = new EventStream(); 46 | 47 | foreach (var tapeRecord in records) 48 | { 49 | stream.Events.AddRange(DeserializeEvent(tapeRecord.Data)); 50 | stream.Version = tapeRecord.Version; 51 | } 52 | return stream; 53 | } 54 | 55 | string IdentityToString(IIdentity id) 56 | { 57 | // in this project all identities produce proper name 58 | return id.ToString(); 59 | } 60 | 61 | public EventStream LoadEventStream(IIdentity id) 62 | { 63 | return LoadEventStream(id, 0, int.MaxValue); 64 | } 65 | 66 | public void AppendToStream(IIdentity id, long originalVersion, ICollection events) 67 | { 68 | if (events.Count == 0) 69 | return; 70 | var name = IdentityToString(id); 71 | var data = SerializeEvent(events.ToArray()); 72 | try 73 | { 74 | _appendOnlyStore.Append(name, data, originalVersion); 75 | } 76 | catch(AppendOnlyStoreConcurrencyException e) 77 | { 78 | // load server events 79 | var server = LoadEventStream(id, 0, int.MaxValue); 80 | // throw a real problem 81 | throw OptimisticConcurrencyException.Create(server.Version, e.ExpectedStreamVersion, id, server.Events); 82 | } 83 | 84 | // technically there should be a parallel process that queries new changes 85 | // from the event store and sends them via messages (avoiding 2PC problem). 86 | // however, for demo purposes, we'll just send them to the console from here 87 | Console.ForegroundColor = ConsoleColor.DarkGreen; 88 | foreach (var @event in events) 89 | { 90 | Console.WriteLine(" {0} r{1} Event: {2}", id,originalVersion, @event); 91 | } 92 | Console.ForegroundColor = ConsoleColor.DarkGray; 93 | } 94 | } 95 | } -------------------------------------------------------------------------------- /Sample/Storage/Files/FileEventStore.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Collections.Generic; 4 | using System.Diagnostics; 5 | using System.IO; 6 | using System.Linq; 7 | using System.Security.Cryptography; 8 | using System.Text; 9 | using System.Threading; 10 | 11 | namespace Sample.Storage.Files 12 | { 13 | /// 14 | /// Simple embedded append-only store that uses Riak.Bitcask model 15 | /// for keeping records 16 | /// This code is frozen to match IDDD book. For latest practices see Lokad.CQRS Project 17 | /// 18 | public class FileAppendOnlyStore : IAppendOnlyStore 19 | { 20 | sealed class Record 21 | { 22 | public readonly byte[] Bytes; 23 | public readonly string Name; 24 | public readonly long Version; 25 | 26 | public Record(byte[] bytes, string name, long version) 27 | { 28 | Bytes = bytes; 29 | Name = name; 30 | Version = version; 31 | } 32 | } 33 | 34 | readonly DirectoryInfo _info; 35 | 36 | // used to synchronize access between threads within a process 37 | 38 | readonly ReaderWriterLockSlim _thread = new ReaderWriterLockSlim(); 39 | // used to prevent writer access to store to other processes 40 | FileStream _lock; 41 | FileStream _currentWriter; 42 | 43 | // caches 44 | readonly ConcurrentDictionary _items = new ConcurrentDictionary(); 45 | DataWithName[] _all = new DataWithName[0]; 46 | 47 | public void Initialize() 48 | { 49 | if (!_info.Exists) 50 | _info.Create(); 51 | // grab the ownership 52 | _lock = new FileStream(Path.Combine(_info.FullName, "lock"), 53 | FileMode.OpenOrCreate, 54 | FileAccess.ReadWrite, 55 | FileShare.None, 56 | 8, 57 | FileOptions.DeleteOnClose); 58 | 59 | LoadCaches(); 60 | } 61 | 62 | public void LoadCaches() 63 | { 64 | try 65 | { 66 | _thread.EnterWriteLock(); 67 | _all = new DataWithName[0]; 68 | foreach (var record in EnumerateHistory()) 69 | { 70 | AddToCaches(record.Name, record.Bytes, record.Version); 71 | } 72 | 73 | } 74 | finally 75 | { 76 | _thread.ExitWriteLock(); 77 | } 78 | } 79 | 80 | IEnumerable EnumerateHistory() 81 | { 82 | // cleanup old pending files 83 | // load indexes 84 | // build and save missing indexes 85 | var datFiles = _info.EnumerateFiles("*.dat"); 86 | 87 | foreach (var fileInfo in datFiles.OrderBy(fi => fi.Name)) 88 | { 89 | // quick cleanup 90 | if (fileInfo.Length == 0) 91 | { 92 | fileInfo.Delete(); 93 | } 94 | 95 | using (var reader = fileInfo.OpenRead()) 96 | using (var binary = new BinaryReader(reader, Encoding.UTF8)) 97 | { 98 | Record result; 99 | while (TryReadRecord(binary, out result)) 100 | { 101 | yield return result; 102 | } 103 | } 104 | } 105 | } 106 | static bool TryReadRecord(BinaryReader binary, out Record result) 107 | { 108 | result = null; 109 | try 110 | { 111 | var version = binary.ReadInt64(); 112 | var name = binary.ReadString(); 113 | var len = binary.ReadInt32(); 114 | var bytes = binary.ReadBytes(len); 115 | var sha = binary.ReadBytes(20); // SHA1. TODO: verify data 116 | if (sha.All(s => s == 0)) 117 | throw new InvalidOperationException("definitely failed"); 118 | 119 | result = new Record(bytes, name, version); 120 | return true; 121 | } 122 | catch (EndOfStreamException) 123 | { 124 | // we are done 125 | return false; 126 | } 127 | catch (Exception ex) 128 | { 129 | Trace.WriteLine(ex); 130 | // Auto-clean? 131 | return false; 132 | } 133 | } 134 | 135 | 136 | public void Dispose() 137 | { 138 | if (!_closed) 139 | Close(); 140 | } 141 | 142 | public FileAppendOnlyStore(string path) 143 | { 144 | _info = new DirectoryInfo(path); 145 | } 146 | 147 | public void Append(string streamName, byte[] data, long expectedStreamVersion = -1) 148 | { 149 | // should be locked 150 | try 151 | { 152 | _thread.EnterWriteLock(); 153 | 154 | var list = _items.GetOrAdd(streamName, s => new DataWithVersion[0]); 155 | if (expectedStreamVersion >= 0) 156 | { 157 | if (list.Length != expectedStreamVersion) 158 | throw new AppendOnlyStoreConcurrencyException(expectedStreamVersion, list.Length, streamName); 159 | } 160 | 161 | EnsureWriterExists(_all.Length); 162 | long commit = list.Length + 1; 163 | 164 | PersistInFile(streamName, data, commit); 165 | AddToCaches(streamName, data, commit); 166 | } 167 | catch 168 | { 169 | Close(); 170 | } 171 | finally 172 | { 173 | _thread.ExitWriteLock(); 174 | } 175 | } 176 | 177 | void PersistInFile(string key, byte[] buffer, long commit) 178 | { 179 | using (var sha1 = new SHA1Managed()) 180 | { 181 | // version, ksz, vsz, key, value, sha1 182 | using (var memory = new MemoryStream()) 183 | { 184 | using (var crypto = new CryptoStream(memory, sha1, CryptoStreamMode.Write)) 185 | using (var binary = new BinaryWriter(crypto, Encoding.UTF8)) 186 | { 187 | binary.Write(commit); 188 | binary.Write(key); 189 | binary.Write(buffer.Length); 190 | binary.Write(buffer); 191 | } 192 | var bytes = memory.ToArray(); 193 | 194 | _currentWriter.Write(bytes, 0, bytes.Length); 195 | } 196 | _currentWriter.Write(sha1.Hash, 0, sha1.Hash.Length); 197 | // make sure that we persist 198 | // NB: this is not guaranteed to work on Linux 199 | _currentWriter.Flush(true); 200 | } 201 | } 202 | 203 | void EnsureWriterExists(long version) 204 | { 205 | if (_currentWriter != null) return; 206 | 207 | var fileName = string.Format("{0:00000000}-{1:yyyy-MM-dd-HHmmss}.dat", version, DateTime.UtcNow); 208 | _currentWriter = File.OpenWrite(Path.Combine(_info.FullName, fileName)); 209 | } 210 | 211 | void AddToCaches(string key, byte[] buffer, long commit) 212 | { 213 | var record = new DataWithVersion(commit, buffer); 214 | _all = ImmutableAdd(_all, new DataWithName(key, buffer)); 215 | _items.AddOrUpdate(key, s => new[] { record }, (s, records) => ImmutableAdd(records, record)); 216 | } 217 | 218 | static T[] ImmutableAdd(T[] source, T item) 219 | { 220 | var copy = new T[source.Length + 1]; 221 | Array.Copy(source, copy, source.Length); 222 | copy[source.Length] = item; 223 | return copy; 224 | } 225 | 226 | public IEnumerable ReadRecords(string streamName, long afterVersion, int maxCount) 227 | { 228 | // no lock is needed. 229 | DataWithVersion[] list; 230 | return _items.TryGetValue(streamName, out list) ? list : Enumerable.Empty(); 231 | } 232 | 233 | public IEnumerable ReadRecords(long afterVersion, int maxCount) 234 | { 235 | // collection is immutable so we don't care about locks 236 | return _all.Skip((int)afterVersion).Take(maxCount); 237 | } 238 | 239 | bool _closed; 240 | 241 | public void Close() 242 | { 243 | using (_lock) 244 | using (_currentWriter) 245 | { 246 | _closed = true; 247 | } 248 | } 249 | 250 | public long GetCurrentVersion() 251 | { 252 | return _all.Length; 253 | } 254 | } 255 | } -------------------------------------------------------------------------------- /Sample/Storage/IAppendOnlyStore.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Runtime.Serialization; 4 | 5 | namespace Sample.Storage 6 | { 7 | public interface IAppendOnlyStore : IDisposable 8 | { 9 | /// 10 | /// 11 | /// Appends data to the stream with the specified name. If is supplied and 12 | /// it does not match server version, then is thrown. 13 | /// 14 | /// 15 | /// The name of the stream, to which data is appended. 16 | /// The data to append. 17 | /// The server version (supply -1 to append without check). 18 | /// thrown when expected server version is 19 | /// supplied and does not match to server version 20 | void Append(string streamName, byte[] data, long expectedStreamVersion = -1); 21 | /// 22 | /// Reads the records by stream name. 23 | /// 24 | /// The key. 25 | /// The after version. 26 | /// The max count. 27 | /// 28 | IEnumerable ReadRecords(string streamName, long afterVersion, int maxCount); 29 | /// 30 | /// Reads the records across all streams. 31 | /// 32 | /// The after version. 33 | /// The max count. 34 | /// 35 | IEnumerable ReadRecords(long afterVersion, int maxCount); 36 | 37 | void Close(); 38 | } 39 | 40 | public sealed class DataWithVersion 41 | { 42 | public readonly long Version; 43 | public readonly byte[] Data; 44 | 45 | public DataWithVersion(long version, byte[] data) 46 | { 47 | Version = version; 48 | Data = data; 49 | } 50 | } 51 | public sealed class DataWithName 52 | { 53 | public readonly string Name; 54 | public readonly byte[] Data; 55 | 56 | public DataWithName(string name, byte[] data) 57 | { 58 | Name = name; 59 | Data = data; 60 | } 61 | } 62 | 63 | /// 64 | /// Is thrown internally, when storage version does not match the condition 65 | /// specified in server request 66 | /// 67 | [Serializable] 68 | public class AppendOnlyStoreConcurrencyException : Exception 69 | { 70 | public long ExpectedStreamVersion { get; private set; } 71 | public long ActualStreamVersion { get; private set; } 72 | public string StreamName { get; private set; } 73 | 74 | protected AppendOnlyStoreConcurrencyException( 75 | SerializationInfo info, 76 | StreamingContext context) 77 | : base(info, context) { } 78 | 79 | public AppendOnlyStoreConcurrencyException(long expectedVersion, long actualVersion, string name) 80 | : base( 81 | string.Format("Expected version {0} in stream '{1}' but got {2}", expectedVersion, name, actualVersion)) 82 | { 83 | StreamName = name; 84 | ExpectedStreamVersion = expectedVersion; 85 | ActualStreamVersion = actualVersion; 86 | } 87 | } 88 | } -------------------------------------------------------------------------------- /Sample/Storage/IEventStore.cs: -------------------------------------------------------------------------------- 1 | #region (c) 2012-2012 Lokad - New BSD License 2 | 3 | // Copyright (c) Lokad 2012-2012, http://www.lokad.com 4 | // This code is released as Open Source under the terms of the New BSD Licence 5 | 6 | #endregion 7 | 8 | using System; 9 | using System.Collections.Generic; 10 | using System.Runtime.Serialization; 11 | 12 | namespace Sample.Storage 13 | { 14 | public interface IEventStore 15 | { 16 | EventStream LoadEventStream(IIdentity id); 17 | EventStream LoadEventStream(IIdentity id, long skipEvents, int maxCount); 18 | /// 19 | /// Appends events to server stream for the provided identity. 20 | /// 21 | /// identity to append to. 22 | /// The expected version (specify -1 to append anyway). 23 | /// The events to append. 24 | /// when new events were added to server 25 | /// since 26 | /// 27 | void AppendToStream(IIdentity id, long expectedVersion, ICollection events); 28 | } 29 | 30 | public class EventStream 31 | { 32 | // version of the event stream returned 33 | public long Version; 34 | // all events in the stream 35 | public List Events = new List(); 36 | } 37 | 38 | /// 39 | /// Is thrown by event store if there were changes since our last version 40 | /// 41 | [Serializable] 42 | public class OptimisticConcurrencyException : Exception 43 | { 44 | public long ActualVersion { get; private set; } 45 | public long ExpectedVersion { get; private set; } 46 | public IIdentity Id { get; private set; } 47 | public IList ActualEvents { get; private set; } 48 | 49 | OptimisticConcurrencyException(string message, long actualVersion, long expectedVersion, IIdentity id, 50 | IList serverEvents) 51 | : base(message) 52 | { 53 | ActualVersion = actualVersion; 54 | ExpectedVersion = expectedVersion; 55 | Id = id; 56 | ActualEvents = serverEvents; 57 | } 58 | 59 | public static OptimisticConcurrencyException Create(long actual, long expected, IIdentity id, 60 | IList serverEvents) 61 | { 62 | var message = string.Format("Expected v{0} but found v{1} in stream '{2}'", expected, actual, id); 63 | return new OptimisticConcurrencyException(message, actual, expected, id, serverEvents); 64 | } 65 | 66 | protected OptimisticConcurrencyException( 67 | SerializationInfo info, 68 | StreamingContext context) 69 | : base(info, context) {} 70 | } 71 | 72 | /// 73 | /// Is supposed to be thrown by the client code, when it fails to resolve concurrency problem 74 | /// 75 | [Serializable] 76 | public class RealConcurrencyException : Exception 77 | { 78 | public RealConcurrencyException() {} 79 | public RealConcurrencyException(string message) : base(message) {} 80 | public RealConcurrencyException(string message, Exception inner) : base(message, inner) {} 81 | 82 | protected RealConcurrencyException( 83 | SerializationInfo info, 84 | StreamingContext context) : base(info, context) {} 85 | } 86 | } -------------------------------------------------------------------------------- /Sample/Storage/MsSql/SqlEventStore.cs: -------------------------------------------------------------------------------- 1 | #region (c) 2012-2012 Lokad - New BSD License 2 | 3 | // Copyright (c) Lokad 2012-2012, http://www.lokad.com 4 | // This code is released as Open Source under the terms of the New BSD Licence 5 | 6 | #endregion 7 | 8 | using System.Collections.Generic; 9 | using System.Data.SqlClient; 10 | 11 | namespace Sample.Storage.MsSql 12 | { 13 | /// 14 | /// This is a SQL event storage simplified to demonstrate essential principles. 15 | /// If you need a more robust MS SQL implementation, check out Event Store of 16 | /// Jonathan Oliver 17 | /// This code is frozen to match IDDD book. For latest practices see Lokad.CQRS Project 18 | /// 19 | public sealed class SqlAppendOnlyStore : IAppendOnlyStore 20 | { 21 | readonly string _connectionString; 22 | 23 | 24 | public SqlAppendOnlyStore(string connectionString) 25 | { 26 | _connectionString = connectionString; 27 | } 28 | 29 | public void Initialize() 30 | { 31 | using (var conn = new SqlConnection(_connectionString)) 32 | { 33 | conn.Open(); 34 | 35 | const string txt = 36 | @"IF NOT EXISTS 37 | (SELECT * FROM sys.objects 38 | WHERE object_id = OBJECT_ID(N'[dbo].[Events]') 39 | AND type in (N'U')) 40 | 41 | CREATE TABLE [dbo].[Events]( 42 | [Id] [int] PRIMARY KEY IDENTITY, 43 | [Name] [nvarchar](50) NOT NULL, 44 | [Version] [int] NOT NULL, 45 | [Data] [varbinary](max) NOT NULL 46 | ) ON [PRIMARY] 47 | "; 48 | using (var cmd = new SqlCommand(txt,conn)) 49 | { 50 | cmd.ExecuteNonQuery(); 51 | } 52 | } 53 | } 54 | 55 | 56 | public void Dispose() 57 | { 58 | 59 | } 60 | 61 | public void Append(string name, byte[] data, long expectedVersion = -1) 62 | { 63 | 64 | using (var conn = new SqlConnection(_connectionString)) 65 | { 66 | conn.Open(); 67 | using (var tx = conn.BeginTransaction()) 68 | { 69 | const string sql = 70 | @"SELECT ISNULL(MAX(Version),0) 71 | FROM Events 72 | WHERE Name=@name"; 73 | 74 | int version; 75 | using (var cmd = new SqlCommand(sql, conn, tx)) 76 | { 77 | cmd.Parameters.AddWithValue("@name", name); 78 | version = (int)cmd.ExecuteScalar(); 79 | if (expectedVersion >= 0) 80 | { 81 | if (version != expectedVersion) 82 | { 83 | throw new AppendOnlyStoreConcurrencyException(version, expectedVersion, name); 84 | } 85 | } 86 | } 87 | const string txt = 88 | @"INSERT INTO Events (Name,Version,Data) 89 | VALUES(@name,@version,@data)"; 90 | 91 | using (var cmd = new SqlCommand(txt, conn, tx)) 92 | { 93 | cmd.Parameters.AddWithValue("@name", name); 94 | cmd.Parameters.AddWithValue("@version", version + 1); 95 | cmd.Parameters.AddWithValue("@data", data); 96 | cmd.ExecuteNonQuery(); 97 | } 98 | tx.Commit(); 99 | } 100 | } 101 | } 102 | 103 | public IEnumerable ReadRecords(string name, long afterVersion, int maxCount) 104 | { 105 | using (var conn = new SqlConnection(_connectionString)) 106 | { 107 | conn.Open(); 108 | const string sql = 109 | @"SELECT TOP (@take) Data, Version FROM Events 110 | WHERE Name = @p1 AND Version > @skip 111 | ORDER BY Version"; 112 | using (var cmd = new SqlCommand(sql, conn)) 113 | { 114 | cmd.Parameters.AddWithValue("@p1", name); 115 | cmd.Parameters.AddWithValue("@take", maxCount); 116 | cmd.Parameters.AddWithValue("@skip", afterVersion); 117 | 118 | 119 | using (var reader = cmd.ExecuteReader()) 120 | { 121 | while (reader.Read()) 122 | { 123 | var data = (byte[])reader["Data"]; 124 | var version = (int)reader["Version"]; 125 | yield return new DataWithVersion(version, data); 126 | } 127 | } 128 | } 129 | } 130 | } 131 | 132 | public IEnumerable ReadRecords(long afterVersion, int maxCount) 133 | { 134 | using (var conn = new SqlConnection(_connectionString)) 135 | { 136 | conn.Open(); 137 | const string sql = 138 | @"SELECT TOP (@take) Data, Name FROM Events 139 | WHERE Id > @skip 140 | ORDER BY Id"; 141 | using (var cmd = new SqlCommand(sql, conn)) 142 | { 143 | 144 | cmd.Parameters.AddWithValue("@take", maxCount); 145 | cmd.Parameters.AddWithValue("@skip", afterVersion); 146 | 147 | using (var reader = cmd.ExecuteReader()) 148 | { 149 | while (reader.Read()) 150 | { 151 | var data = (byte[])reader["Data"]; 152 | var name = (string)reader["Name"]; 153 | yield return new DataWithName(name, data); 154 | } 155 | } 156 | } 157 | } 158 | } 159 | 160 | public void Close() 161 | { 162 | 163 | } 164 | } 165 | } -------------------------------------------------------------------------------- /Sample/Storage/MySql/MySqlEventStore.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using MySql.Data.MySqlClient; 3 | 4 | namespace Sample.Storage.MySql 5 | { 6 | /// 7 | /// This is a SQL event storage for MySQL, simplified to demonstrate 8 | /// essential principles. 9 | /// If you need a more robust mySQL implementation, check out Event Store of 10 | /// Jonathan Oliver 11 | /// This code is frozen to match IDDD book. For latest practices see Lokad.CQRS Project 12 | /// 13 | public sealed class MySqlEventStore : IAppendOnlyStore 14 | { 15 | readonly string _connectionString; 16 | 17 | 18 | public MySqlEventStore(string connectionString) 19 | { 20 | _connectionString = connectionString; 21 | 22 | } 23 | 24 | public void Initialize() 25 | { 26 | using (var conn = new MySqlConnection(_connectionString)) 27 | { 28 | conn.Open(); 29 | 30 | const string txt = @" 31 | CREATE TABLE IF NOT EXISTS `ES_Events` ( 32 | `Id` int NOT NULL AUTO_INCREMENT, 33 | `Name` nvarchar(50) NOT NULL, 34 | `Version` int NOT NULL, 35 | `Data` LONGBLOB NOT NULL 36 | )"; 37 | using (var cmd = new MySqlCommand(txt, conn)) 38 | { 39 | cmd.ExecuteNonQuery(); 40 | } 41 | } 42 | } 43 | 44 | public void Dispose() 45 | { 46 | 47 | } 48 | 49 | public void Append(string name, byte[] data, long expectedVersion) 50 | { 51 | using (var conn = new MySqlConnection(_connectionString)) 52 | { 53 | conn.Open(); 54 | using (var tx = conn.BeginTransaction()) 55 | { 56 | const string sql = 57 | @"SELECT COALESCE(MAX(Version),0) 58 | FROM `ES_Events` 59 | WHERE Name=?name"; 60 | int version; 61 | using (var cmd = new MySqlCommand(sql, conn, tx)) 62 | { 63 | cmd.Parameters.AddWithValue("?name", name); 64 | version = (int)cmd.ExecuteScalar(); 65 | if (expectedVersion != -1) 66 | { 67 | if (version != expectedVersion) 68 | { 69 | throw new AppendOnlyStoreConcurrencyException(version, expectedVersion, name); 70 | } 71 | } 72 | } 73 | 74 | const string txt = 75 | @"INSERT INTO `ES_Events` (`Name`, `Version`, `Data`) 76 | VALUES(?name, ?version, ?data)"; 77 | 78 | using (var cmd = new MySqlCommand(txt, conn, tx)) 79 | { 80 | cmd.Parameters.AddWithValue("?name", name); 81 | cmd.Parameters.AddWithValue("?version", version+1); 82 | cmd.Parameters.AddWithValue("?data", data); 83 | cmd.ExecuteNonQuery(); 84 | } 85 | tx.Commit(); 86 | } 87 | } 88 | } 89 | 90 | public IEnumerable ReadRecords(string name, long afterVersion, int maxCount) 91 | { 92 | using (var conn = new MySqlConnection(_connectionString)) 93 | { 94 | conn.Open(); 95 | const string sql = 96 | @"SELECT `Data`, `Version` FROM `ES_Events` 97 | WHERE `Name` = ?name AND `Version`>?version 98 | ORDER BY `Version` 99 | LIMIT 0,?take"; 100 | using (var cmd = new MySqlCommand(sql, conn)) 101 | { 102 | cmd.Parameters.AddWithValue("?name", name); 103 | cmd.Parameters.AddWithValue("?version", afterVersion); 104 | cmd.Parameters.AddWithValue("?take", maxCount); 105 | using (var reader = cmd.ExecuteReader()) 106 | { 107 | while (reader.Read()) 108 | { 109 | var data = (byte[])reader["Data"]; 110 | var version = (int)reader["Version"]; 111 | yield return new DataWithVersion(version, data); 112 | } 113 | } 114 | } 115 | } 116 | } 117 | 118 | public IEnumerable ReadRecords(long afterVersion, int maxCount) 119 | { 120 | using (var conn = new MySqlConnection(_connectionString)) 121 | { 122 | conn.Open(); 123 | const string sql = 124 | @"SELECT `Data`, `Name` FROM `ES_Events` 125 | WHERE `Id`>?after 126 | ORDER BY `Id` 127 | LIMIT 0,?take"; 128 | using (var cmd = new MySqlCommand(sql, conn)) 129 | { 130 | cmd.Parameters.AddWithValue("?after", afterVersion); 131 | cmd.Parameters.AddWithValue("?take", maxCount); 132 | using (var reader = cmd.ExecuteReader()) 133 | { 134 | while (reader.Read()) 135 | { 136 | var data = (byte[])reader["Data"]; 137 | var name = (string)reader["Name"]; 138 | yield return new DataWithName(name, data); 139 | } 140 | } 141 | } 142 | } 143 | } 144 | 145 | 146 | 147 | public void Close() 148 | { 149 | 150 | } 151 | } 152 | } -------------------------------------------------------------------------------- /Sample/Storage/ReadMe.md: -------------------------------------------------------------------------------- 1 | > Rinat Abdullin, 2012-07-15 2 | 3 | This is a development snapshot of various event store implementations, 4 | based on IAppendOnlyStore concept. These stores are pushed here from Lokad 5 | production code, to accompany IDDD book by Vaughn Vernon. 6 | 7 | For the latest version of stores (production-capable), check out Lokad.CQRS 8 | Project (after the book release). 9 | 10 | -------------------------------------------------------------------------------- /UnitTests/CustomerAggregate/WhenChargeCustomer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using NUnit.Framework; 3 | using Sample.Domain; 4 | 5 | namespace Sample.CustomerAggregate 6 | { 7 | /// 8 | /// Given-when-then unit tests for . 9 | /// See Readme file in folders above for explanations or 10 | /// 'framework.cs' for the testing infrastructure. 11 | /// 12 | public class WhenChargeCustomer : customer_specs 13 | { 14 | [Test] 15 | public void given_non_existent_customer() 16 | { 17 | Given = NoEvents; 18 | When = c => c.Charge("charge", 1m.Eur(), DateTime.UtcNow); 19 | ThenException = ex => ex.Message == "Customer currency was not assigned!"; 20 | } 21 | [Test] 22 | public void given_existing_customer_with_balance() 23 | { 24 | Given = new IEvent[] 25 | { 26 | new CustomerCreated 27 | { 28 | Id = new CustomerId(2), 29 | Name = "Microsoft", 30 | Currency = Currency.Eur 31 | }, 32 | new CustomerPaymentAdded 33 | { 34 | Id = new CustomerId(2), 35 | NewBalance = 1000m.Eur(), 36 | Payment = 1000m.Eur(), 37 | PaymentName = "Bonus", 38 | Transaction = 1 39 | } 40 | }; 41 | When = c => c.Charge("Sales forecast fee", 200m.Eur(), new DateTime(2012, 3, 2)); 42 | Then = new IEvent[] 43 | { 44 | new CustomerChargeAdded 45 | { 46 | Charge = 200m.Eur(), 47 | ChargeName = "Sales forecast fee", 48 | Id = new CustomerId(2), 49 | NewBalance = 800m.Eur(), 50 | TimeUtc = new DateTime(2012, 3, 2), 51 | Transaction = 2 52 | } 53 | }; 54 | } 55 | } 56 | } -------------------------------------------------------------------------------- /UnitTests/CustomerAggregate/WhenCreateCustomer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using NUnit.Framework; 3 | using Sample.Domain; 4 | 5 | namespace Sample.CustomerAggregate 6 | { 7 | /// 8 | /// Given-when-then unit tests for . 9 | /// See Readme file in folders above for explanations or 10 | /// 'framework.cs' for the testing infrastructure. 11 | /// 12 | public class WhenCreateCustomer : customer_specs 13 | { 14 | [Test] 15 | public void given_new_customer_and_bonus() 16 | { 17 | // dependencies 18 | var pricing = new TestPricingService(17m); 19 | 20 | // call 21 | When = customer => customer.Create( 22 | new CustomerId(1), 23 | "Lokad", 24 | Currency.Eur, pricing, new DateTime(2012, 07, 16)); 25 | 26 | // expectations 27 | Then = new IEvent[] 28 | { 29 | new CustomerCreated 30 | { 31 | Currency = Currency.Eur, 32 | Id = new CustomerId(1), 33 | Name = "Lokad", 34 | Created = new DateTime(2012, 07, 16) 35 | }, 36 | new CustomerPaymentAdded 37 | { 38 | Id = new CustomerId(1), 39 | NewBalance = 17m.Eur(), 40 | Transaction = 1, 41 | Payment = 17m.Eur(), 42 | PaymentName = "Welcome bonus", 43 | TimeUtc = new DateTime(2012, 07, 16) 44 | } 45 | }; 46 | } 47 | 48 | [Test] 49 | public void given_new_customer_and_no_bonus() 50 | { 51 | // dependencies 52 | var pricing = new TestPricingService(0); 53 | 54 | // call 55 | When = customer => customer.Create( 56 | new CustomerId(1), 57 | "Lokad", 58 | Currency.Rur, pricing, new DateTime(2012, 07, 16)); 59 | 60 | // expectations 61 | Then = new IEvent[] 62 | { 63 | new CustomerCreated 64 | { 65 | Currency = Currency.Rur, 66 | Id = new CustomerId(1), 67 | Name = "Lokad", 68 | Created = new DateTime(2012, 07, 16) 69 | }, 70 | }; 71 | } 72 | } 73 | } -------------------------------------------------------------------------------- /UnitTests/CustomerAggregate/WhenRenameCustomer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using NUnit.Framework; 3 | using Sample.Domain; 4 | 5 | namespace Sample.CustomerAggregate 6 | { 7 | /// 8 | /// Given-when-then unit tests for . 9 | /// See Readme file in folders above for explanations or 10 | /// 'framework.cs' for the testing infrastructure. 11 | /// 12 | public class WhenRenameCustomer : customer_specs 13 | { 14 | [Test] 15 | public void given_matching_name() 16 | { 17 | Given = new IEvent[] 18 | { 19 | new CustomerCreated 20 | { 21 | Id = new CustomerId(1), 22 | Currency = Currency.Eur, 23 | Name = "Lokad" 24 | } 25 | }; 26 | When = c => c.Rename("Lokad", DateTime.UtcNow); 27 | Then = NoEvents; 28 | } 29 | 30 | [Test] 31 | public void given_different_name() 32 | { 33 | Given = new IEvent[] 34 | { 35 | new CustomerCreated 36 | { 37 | Id = new CustomerId(1), 38 | Currency = Currency.Eur, 39 | Name = "Lokad" 40 | } 41 | }; 42 | 43 | When = c => c.Rename("Lokad SAS", new DateTime(2012, 07, 16)); 44 | Then = new IEvent[] 45 | { 46 | new CustomerRenamed 47 | { 48 | Id = new CustomerId(1), 49 | Name = "Lokad SAS", 50 | OldName = "Lokad", 51 | Renamed = new DateTime(2012, 07, 16) 52 | } 53 | }; 54 | } 55 | } 56 | } -------------------------------------------------------------------------------- /UnitTests/ReadMe.md: -------------------------------------------------------------------------------- 1 | > Rinat Abdullin, 2012-07-16 2 | 3 | This project contains simple testing "framework" implementation for writing 4 | self-documenting unit-tests for A+ES. It uses NUnit as underlying unit test. 5 | 6 | The purpose of this implementation is to demonstrate principles of testing 7 | for Aggregates implemented with Event Sourcing (as described in IDDD book by 8 | Vaughn Vernon). If you are interested in more detailed and deep implementation 9 | of specifications - check out [Lokad.CQRS](http://lokad.github.com/lokad-cqrs/). 10 | 11 | These unit tests are known as "specifications" or "given-when-then" tests (GWT). 12 | Within such tests we establish that: 13 | 14 | * given certain events; 15 | * when a command is executed (our case); 16 | * then we expect some specific events to happen (or an exception is thrown). 17 | 18 | Each unit test defines a case, which not only serves as a unit test, but can also 19 | print human-readable description, when you run it. General rule of thumb is 20 | to have at least one test fixture per aggregate method. This fixture will 21 | be named by method and will have multiple tests which verify varuous use cases. 22 | 23 | Please, see "framework.cs" for the actual infrastructure or "when_*" classes 24 | for actual unit test fixtures with partial self-documenting capabilities. 25 | 26 | If you are interested in more: 27 | 28 | * Read about [Specifications](http://cqrsguide.com/doc:specification) 29 | * See [Lokad.CQRS](http://lokad.github.com/lokad-cqrs/) sample project for more 30 | detailed and deep implementation -------------------------------------------------------------------------------- /UnitTests/UnitTests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Debug 5 | AnyCPU 6 | 8.0.30703 7 | 2.0 8 | {EE57197B-B092-4309-A650-FC8FF453070E} 9 | Library 10 | Properties 11 | Sample 12 | UnitTests 13 | v4.0 14 | 512 15 | 16 | 17 | true 18 | full 19 | false 20 | bin\Debug\ 21 | DEBUG;TRACE 22 | prompt 23 | 4 24 | 25 | 26 | pdbonly 27 | true 28 | bin\Release\ 29 | TRACE 30 | prompt 31 | 4 32 | 33 | 34 | 35 | ..\Library\NUnit\nunit.framework.dll 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | {D79777B6-AFD9-4CCE-824B-98C704C00AEE} 50 | RunMe 51 | 52 | 53 | 54 | 55 | 56 | 57 | 64 | -------------------------------------------------------------------------------- /UnitTests/framework.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Linq.Expressions; 6 | using System.Runtime.Serialization.Formatters.Binary; 7 | using NUnit.Framework; 8 | using Sample.Domain; 9 | 10 | namespace Sample 11 | { 12 | /// 13 | /// 14 | /// /// This is simplified version of Greg Young's Simple Testing, 15 | /// adjusted for specification testing of A+ES entities, which is 16 | /// friendly with Resharper 17 | /// 18 | /// Check Lokad.CQRS and Simple Testing for more deep implementations 19 | /// 20 | public abstract class customer_specs 21 | { 22 | // this is our syntax for tests 23 | /// Events that consistute aggregate history 24 | public IList Given; 25 | 26 | /// aggregate method that we call 27 | public Expression> When; 28 | 29 | /// Assign here events that we expect to be published 30 | public IList Then 31 | { 32 | set { AssertCustomerGWT(Given, When, value); } 33 | } 34 | 35 | 36 | public Expression> ThenException 37 | { 38 | set 39 | { 40 | try 41 | { 42 | ExecCustomer(Given, When); 43 | Assert.Fail("Expected exception: " + value); 44 | } 45 | catch (Exception ex) 46 | { 47 | Console.WriteLine("Expect exception: " + value); 48 | if (!value.Compile()(ex)) 49 | throw; 50 | } 51 | } 52 | } 53 | 54 | [SetUp] 55 | public void Setup() 56 | { 57 | // just reset the specification 58 | Given = NoEvents; 59 | When = null; 60 | } 61 | 62 | public readonly IEvent[] NoEvents = new IEvent[0]; 63 | 64 | static void AssertCustomerGWT(ICollection given, Expression> when, 65 | ICollection then) 66 | { 67 | var changes = ExecCustomer(given, when); 68 | 69 | if (then.Count == 0) Console.WriteLine("Expect no events"); 70 | else 71 | foreach (var @event in then) 72 | { 73 | Console.WriteLine("Expect: " + @event); 74 | } 75 | 76 | AssertEquality(then.ToArray(), changes.ToArray()); 77 | } 78 | 79 | 80 | static IEnumerable ExecCustomer(ICollection given, Expression> when) 81 | { 82 | if (given.Count == 0) Console.WriteLine("Given no events"); 83 | foreach (var @event in given) 84 | { 85 | Console.WriteLine("Given: " + @event); 86 | } 87 | 88 | var customer = new Customer(given); 89 | 90 | PrintWhen(when); 91 | when.Compile()(customer); 92 | return customer.Changes; 93 | } 94 | 95 | static void PrintWhen(Expression> when) 96 | { 97 | // this output can be made prettier, if we 98 | // either use expression helpers (see Greg Young's Simple testing for that) 99 | // or use commands at the application level (see tests in Lokad.CQRS for that) 100 | Console.WriteLine(); 101 | Console.WriteLine("When: " + when); 102 | Console.WriteLine(); 103 | } 104 | 105 | 106 | static void AssertEquality(IEvent[] expected, IEvent[] actual) 107 | { 108 | // in this method we assert full equality between events by serializing 109 | // and comparing data 110 | var actualBytes = SerializeEventsToBytes(actual); 111 | var expectedBytes = SerializeEventsToBytes(expected); 112 | bool areEqual = actualBytes.SequenceEqual(expectedBytes); 113 | if (areEqual) return; 114 | // however if events differ, and this can be seen in human-readable version, 115 | // then we display human-readable version (derived from ToString()) 116 | CollectionAssert.AreEqual( 117 | expected.Select(s => s.ToString()).ToArray(), 118 | actual.Select(s => s.ToString()).ToArray()); 119 | 120 | CollectionAssert.AreEqual(expectedBytes, actualBytes, 121 | "Expected events differ from actual, but differences are not represented in ToString()"); 122 | } 123 | 124 | static byte[] SerializeEventsToBytes(IEvent[] actual) 125 | { 126 | // this helper class transforms events to their binary representation 127 | BinaryFormatter formatter = new BinaryFormatter(); 128 | using (var mem = new MemoryStream()) 129 | { 130 | formatter.Serialize(mem, actual); 131 | return mem.ToArray(); 132 | } 133 | } 134 | } 135 | 136 | /// 137 | /// This simple class allows our tests to stay clean and decopled 138 | /// from complicated mock frameworks 139 | /// 140 | class TestPricingService : IPricingService 141 | { 142 | readonly decimal _substitute; 143 | 144 | public TestPricingService(decimal substitute) 145 | { 146 | _substitute = substitute; 147 | } 148 | 149 | public CurrencyAmount GetOverdraftThreshold(Currency currency) 150 | { 151 | return new CurrencyAmount(_substitute, currency); 152 | } 153 | 154 | public CurrencyAmount GetWelcomeBonus(Currency currency) 155 | { 156 | return new CurrencyAmount(_substitute, currency); 157 | } 158 | } 159 | } -------------------------------------------------------------------------------- /lokad-iddd-sample.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 11.00 3 | # Visual Studio 2010 4 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RunMe", "Sample\RunMe.csproj", "{D79777B6-AFD9-4CCE-824B-98C704C00AEE}" 5 | EndProject 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTests", "UnitTests\UnitTests.csproj", "{EE57197B-B092-4309-A650-FC8FF453070E}" 7 | EndProject 8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{BE08A348-FF11-407A-95C2-097CF42A2AD3}" 9 | ProjectSection(SolutionItems) = preProject 10 | README.md = README.md 11 | EndProjectSection 12 | EndProject 13 | Global 14 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 15 | Debug|Any CPU = Debug|Any CPU 16 | Release|Any CPU = Release|Any CPU 17 | EndGlobalSection 18 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 19 | {D79777B6-AFD9-4CCE-824B-98C704C00AEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 20 | {D79777B6-AFD9-4CCE-824B-98C704C00AEE}.Debug|Any CPU.Build.0 = Debug|Any CPU 21 | {D79777B6-AFD9-4CCE-824B-98C704C00AEE}.Release|Any CPU.ActiveCfg = Release|Any CPU 22 | {D79777B6-AFD9-4CCE-824B-98C704C00AEE}.Release|Any CPU.Build.0 = Release|Any CPU 23 | {EE57197B-B092-4309-A650-FC8FF453070E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 24 | {EE57197B-B092-4309-A650-FC8FF453070E}.Debug|Any CPU.Build.0 = Debug|Any CPU 25 | {EE57197B-B092-4309-A650-FC8FF453070E}.Release|Any CPU.ActiveCfg = Release|Any CPU 26 | {EE57197B-B092-4309-A650-FC8FF453070E}.Release|Any CPU.Build.0 = Release|Any CPU 27 | EndGlobalSection 28 | GlobalSection(SolutionProperties) = preSolution 29 | HideSolutionNode = FALSE 30 | EndGlobalSection 31 | EndGlobal 32 | --------------------------------------------------------------------------------