├── .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 |
--------------------------------------------------------------------------------