본문 바로가기

Framework/CQRS

Cafe CQRS (Domain Logic) - part 3

Our First Command and Event

 

모든 도메인 비즈니스 로직 시나리오는 Table을 오픈하는 것부터 시작하겠습니다.

먼저, TabOpened 이벤트를 정의하겠습니다. 이벤트의 구분을 위해 이벤트 guid 를 부여하는것이 핵심입니다.

public class OpenTab
{
	public Guid Id;
	public int TableNumber;
	public string Waiter;
}

 

커맨드도 거의 비슷합니다.

public class TabOpened
{
	public Guid Id;
	public int TableNumber;
	public string Waiter;
}

 

일반적으로 커맨드와 이벤트에 대한 suffix (접미사)등을 추가해야 하는 지에 대한 생각이 있을지 모르겠으나, 이 튜토리얼에서는 비즈니스적인 관점으로 접근하기 때문에 접미사나 접두어를 붙이지 않을 것입니다. (개인적으로는 이 부분에 이견이 좀 있습니다. 물론 DDD 관점에서 봤을 때는 suffix 가 중요하지 않긴 합니다. 커맨드, 이벤트 (나아가서는 클래스 또한...)등은 해당 비즈니스를 정확하게 표현할 수 있는 이름이면 좋긴 합니다. 예를 들면 Cook me a spicy burrito! 등과 같은 커맨드는 Cook(spicy burrito) 등으로 명명하거나, CookSpicyBurrito 등으로 하는 것이 좋을 수 있습니다. 허나 규모가 커지고, 실제 현장에서는 많은 개발자들이 현장에서 프로젝트에 투입되서 개발을 진행하다 보면 문서화나 전체 커맨드, 이벤트 리스팅 등이나, 유닛테스트를 위한 자동 코드 생성 툴을 사용하는 경우가 많기 때문에 그러한 부분의 효율성을 위해서 suffix 를 붙이는 것도 나쁘진 않을 것으로 보입니다.)

 

Building Our First Test

public class TabAggregate : Aggregate
{
}

 

Aggregate base class 에서 상속해서 TabAggregate를 구현하겠습니다.

 

[TestClass]
public class TabTests : BDDTest<TabAggregate>
{
	private Guid _testId;
	private int _testTable;
	private string _testWaiter;

	[TestInitialize]
	public void Initialize()
	{
		_testId = Guid.NewGuid();
		_testTable = 42;
		_testWaiter = "Starter";
	}

	[TestMethod]
	public void CanOpenANewTab()
	{
		Test(
			Given(),
			When(new OpenTab
			{
				Id = _testId,
				TableNumber = _testTable,
				Waiter = _testWaiter
			}),
			Then(new TabOpened
			{
				Id = _testId,
				TableNumber = _testTable,
				Waiter = _testWaiter
			}));
	}
}

 

BDDTest 에서 상속받아서 TabTests 를 만드는데, 이 테스트는 커맨드(사용자 액션)가 발생될 때, 대응되는 이벤트가 발생되는지를 테스트 합니다. 물론 현재 단계에서는 이 테스트는 실패할 것입니다. TDD(Test Driven Development)적인 관점에서 볼 때, 이러한 테스트를 통해서 도메인 로직을 손쉽게 테스트 할 수 있으며, 유지보수 및 기능 추가에 유연하게 대처할 수 있습니다.

 

From Fail to Pass

 

솔루션 구조입니다. (이 튜토리얼에서는 NUnit 을 사용했지만,  저는 Visual Studio Unit Test 를 사용할 것입니다. 큰 차이는 없습니다.)

첫번째 테스트는 역시 예상대로 실패하게 되는데, 테스트 결과에 다음 작업 방향이 제시됩니다.

 

이 테스트는 TabAggregate 가 OpenTab 에 대한 핸들링을 추가하면 해결됩니다.

public class TabAggregate : Aggregate,
	IHandleCommand<OpenTab>
{
	public IEnumerable Handle(OpenTab command)
	{
		yield return new TabOpened
		{
			Id = command.Id,
			TableNumber = command.TableNumber,
			Waiter = command.Waiter
		};
	}
}

추가적으로, 여기서 솔루션내의 참조 관계가 중요한데, 현재 TabAggregate 를 커맨드 프로젝트에 위치하게 했으므로, 커맨드 프로젝트에서 이벤트 프로젝트를 참조해야 하고, 테스트 프로젝트의 경우에는 테스트 편의성을 위해 전부 참조관계를 해놓는 것이 중요합니다.

OpenTab 커맨드에 대한 핸들링은 간단한데, 단순히 TabOpened 이벤트를 발생시킵니다. 다만, IEnumerable 로 리턴하는 것에 주의해야 하는데, 커맨드 하나가 실행 될때, 이벤트가 발생하지 않거나 여러 이벤트가 생길 수 있기 때문입니다.

 

Taking Orders

 

이제 다음은 주문 처리 과정입니다. 먼저 DrinksOrdered, FoodOrdered 이벤트를 생성합니다.

public class OrderedItem
{
	public int MenuNumber { get; set; }
	public string Description { get; set; }
	public bool IsDrink { get; set; }
	public decimal Price { get; set; }
}

public class DrinksOrdered
{
	public Guid Id;
	public List<OrderedItem> Items;
}

public class FoodOrdered
{
	public Guid Id;
	public List<OrderedItem> Items;
}

재사용에 문제가 없도록 이벤트를 독립적으로 만드는 것이 중요합니다.

 

대응하는 커맨드는 PlaceOrder 입니다.

public class PlaceOrder
{
	public Guid Id;
	public List<OrderedItem> Items;
}

 

다음 테스트를 위해 다음의 예외 클래스를 정의할텐데, 도메인 로직의 관점에서 주문을 하기 위해서는 손님이 테이블에 앉아서 주문할 것이기 때문에 사전에 테이블이 오픈이 되어야 합니다.(물론 다른 로직으로 대기 손님이 미리 주문을 할 수 있지만, 예제의 단순함을 위해 고려하지 않겠습니다.) 아직 실패 모드에 대해서는 정의하지 않았는데, 이는 테스트를 진행하는 과정 중에서 더 도출이 될것입니다.

[Serializable]
public class TabNotOpenException : Exception
{
	public TabNotOpenException() { }
	public TabNotOpenException(string message) : base(message) { }
	public TabNotOpenException(string message, Exception inner) : base(message, inner) { }
	protected TabNotOpenException(
	  System.Runtime.Serialization.SerializationInfo info,
	  System.Runtime.Serialization.StreamingContext context)
		: base(info, context) { }
}

예외 클래스 별로 하나의 파일을 만들 수도 있지만, Exceptions.cs 를 만들고, 해당 파일에 전부 모아놓는 것도 하나의 방법입니다.

[TestMethod]
public void CanNotOrderWithUnopenedTab()
{
	Test(
		Given(),
		When(new PlaceOrder
		{
			Id = _testId,
			Items = new List<OrderedItem> { _testDrink1 }
		}),
		ThenFailWith<TabNotOpenException>());
}

 

일단 이 테스트를 통과 시키기 위해 command handler를 등록합니다.

 

public IEnumerable Handle(PlaceOrder command)
{
	throw new TabNotOpenException();
}

음료와 음식조합으로 몇 가지 테스트를 더 추가합니다.

[TestMethod]
public void CanPlaceDrinksOrder()
{
	Test(
		Given(new TabOpened
		{
			Id = _testId,
			TableNumber = _testTable,
			Waiter = _testWaiter
		}),
		When(new PlaceOrder
		{
			Id = _testId,
			Items = new List<OrderedItem> { _testDrink1, _testDrink2 }
		}),
		Then(new DrinksOrdered
		{
			Id = _testId,
			Items = new List<OrderedItem> { _testDrink1, _testDrink2 }
		}));
}

[TestMethod]
public void CanPlaceFoodOrder()
{
	Test(
		Given(new TabOpened
		{
			Id = _testId,
			TableNumber = _testTable,
			Waiter = _testWaiter
		}),
		When(new PlaceOrder
		{
			Id = _testId,
			Items = new List<OrderedItem> { _testFood1, _testFood2 }
		}),
		Then(new FoodOrdered
		{
			Id = _testId,
			Items = new List<OrderedItem> { _testFood1, _testFood2 }
		}));
}

[TestMethod]
public void CanPlaceFoodAndDrinkOrder()
{
	Test(
		Given(new TabOpened
		{
			Id = _testId,
			TableNumber = _testTable,
			Waiter = _testWaiter
		}),
		When(new PlaceOrder
		{
			Id = _testId,
			Items = new List<OrderedItem> { _testFood1, _testDrink2 }
		}),
		Then(new DrinksOrdered
		{
			Id = _testId,
			Items = new List<OrderedItem> { _testDrink2 }
		},
		new FoodOrdered
		{
			Id = _testId,
			Items = new List<OrderedItem> { _testFood1 }
		}));
}

 

 

이제 이 테스트를 통과 시키기 위해 Aggregate 쪽에 작업을 해야 하는데, Aggregate 에서 ApplyEvent 를 적용시키면, 테스트는 통과할 것입니다.

public class TabAggregate : Aggregate,
	IHandleCommand<OpenTab>,
	IHandleCommand<PlaceOrder>,
	IApplyEvent<TabOpened>
{
	private bool _open = false;

	public void Apply(TabOpened e)
	{
		_open = true;
	}

	// command handlers, omitted for brevity
}

 

위의 command handler 쪽에서 항상 exception 을 발생시키도록 했기 때문에, 이제 그 부분에 비즈니스 로직을 적용해야 합니다.

public IEnumerable Handle(PlaceOrder command)
{
	if (!_open) throw new TabNotOpenException();

	var drink = command.Items.Where(i => i.IsDrink).ToList();

	if (drink.Any())
	{
		yield return new DrinksOrdered
		{
			Id = command.Id,
			Items = drink
		};
	}

	var food = command.Items.Where(i => !i.IsDrink).ToList();

	if (food.Any())
	{
		yield return new FoodOrdered
		{
			Id = command.Id,
			Items = food
		};
	}
}

이제 테스트가 Given - When 프로세스에 맞춰서 실행이 되면서 통과하는 것을 볼 수 있을 것입니다. 추후 실제 production 시나리오에서도 같은 형태로 작업을 할 수 있는데, 다만 event store에 저장되고, 불러지는 부분만 다를 것입니다.

 

 

Serving - and a Pattern of Intentful Testing

 

이제 주문한 음료를 서빙하는 부분을 고민해 보겠습니다. 커맨드는 MarkDrinksServed 가 될 것이고, 생성되는 이벤트는 DrinksServed 이벤트 형태로 생성합니다. 이 이벤트는 도메인관점을 반영하고 커맨드는 유저 관점을 반영합니다.

public class DrinksServed
{
	public Guid Id;
	public List<int> MenuNumbers;
}

 

public class MarkDrinksServed
{
	public Guid Id;
	public List<int> MenuNumbers;
}

 

[TestMethod]
public void OrderedDrinksCanBeServed()
{
	Test(
		Given(new TabOpened
		{
			Id = _testId,
			TableNumber = _testTable,
			Waiter = _testWaiter
		},
		new DrinksOrdered
		{
			Id = _testId,
			Items = new List<OrderedItem> { _testDrink1, _testDrink2 }
		}),
		When(new MarkDrinksServed
		{
			Id = _testId,
			MenuNumbers = new List<int> { _testDrink1.MenuNumber, _testDrink2.MenuNumber }
		}),
		Then(new DrinksServed
		{
			Id = _testId,
			MenuNumbers = new List<int> { _testDrink1.MenuNumber, _testDrink2.MenuNumber }
		}));
}

단순하게 생각하면 여기서 바로 모든 테스트가 가능하다고 생각 할 수 있을거라고 생각할 수 있는데, 과연 그럴지는 좀 더 생각해 봐야 합니다. 이 테스트가 통과되려면 어떻게 해야되는지 보면, 테스트 가 실패하는 첫번째 이유는 event applier 가 없어서 입니다.

이 부분은 단순히 apply 를 적용하면 됩니다.

public void Apply(DrinksOrdered e)
{
}

그 다음 또 fail 이 떨어질 텐데, 이건 커맨드 핸들러에서 이벤트를 발생시키면 패스 할 수 있습니다.

public IEnumerable Handle(MarkDrinksServed command)
{
	yield return new DrinksServed
	{
		Id = command.Id,
		MenuNumbers = command.MenuNumbers
	};
}

이런식으로 계속 하게 되면 테스트 자체는 통과할 수 있으나 이렇게 되면 과연 도메인 로직에 대한 테스트가 정확하게 이루어질지는 생각해 봐야 합니다. 반면 테스트에서 예외를 던지는 방식으로 하게 되면 좀 더 흥미로운 테스트가 가능합니다. (원문에서는 이걸 sad path test 라고 칭함)

[Serializable]
public class DrinksNotOutstandingException : Exception
{
	public DrinksNotOutstandingException() { }
	public DrinksNotOutstandingException(string message) : base(message) { }
	public DrinksNotOutstandingException(string message, Exception inner) : base(message, inner) { }
	protected DrinksNotOutstandingException(
	  System.Runtime.Serialization.SerializationInfo info,
	  System.Runtime.Serialization.StreamingContext context) : base(info, context)
	{ }
}

예를 들어서, 주문되지 않는 음료는 서빙할 수 없다라는 로직이나, 이미 서빙된 음료에 대해서 다시 서빙이 이뤄지지 않아야 한다든지의 로직에 대해서도 예외를 발생시켜서 테스트 할 수 있습니다.

[TestMethod]
public void CanNotServeAnUnorderedDrink()
{
	Test(
		Given(new TabOpened
		{
			Id = _testId,
			TableNumber = _testTable,
			Waiter = _testWaiter
		},
		new DrinksOrdered
		{
			Id = _testId,
			Items = new List<OrderedItem> { _testDrink1 }
		}),
		When(new MarkDrinksServed
		{
			Id = _testId,
			MenuNumbers = new List<int> { _testDrink2.MenuNumber }
		}),
		ThenFailWith<DrinksNotOutstandingException>());
}

물론 UI 단에서 막을 수도 있지만, 다중 사용자 환경에서 생각해본다면 Aggregate 단에서 처리가 가능해야 합니다. 도메인 로직 차원에서 위의 경우를 제어할 수 있어야 된다는 관점에서 보면, 도메인 로직에서 예외의 발생/처리 부분이 핸들링 되어야 할 것입니다.

주문이 추가된 것들에 대해서 두번째 테스트는 실패할 텐데, 이 부분을 해결하기 위해서 Aggregate 에서 주문에 대한 상태를 유지해야 합니다. 또한 도메인 로직내에서  해당 주문 컬렉션에 넣고 빼는 작업을 할 수 있는 메서드가 필요해집니다.

private List<int> _outstandingDrinks = new List<int>();

public void Apply(DrinksOrdered e)
{
	_outstandingDrinks.AddRange(e.Items.Select(i => i.MenuNumber));
}
private bool AreDrinksOutstanding(List<int> menuNumbers)
{
	var currentOutstanding = new List<int>(_outstandingDrinks);
	foreach (var num in menuNumbers)
	{
		if (currentOutstanding.Contains(num))
		{
			currentOutstanding.Remove(num);
		}
		else
		{
			return false;
		}
	}

	return true;
}

최종적으로 커맨드 핸들러에 적용합니다.

public IEnumerable Handle(MarkDrinksServed command)
{
	if (!AreDrinksOutstanding(command.MenuNumbers))
		throw new DrinksNotOutstandingException();
	yield return new DrinksServed
	{
		Id = command.Id,
		MenuNumbers = command.MenuNumbers
	};
}

이제서야 좀 의미있는 테스트가 됐습니다. 서빙에 대한 테스트 코드는 다음과 같이 작성합니다.

[TestMethod]
public void CanNotServeAnOrderedDrinkTwice()
{
	Test(
		Given(new TabOpened
		{
			Id = _testId,
			TableNumber = _testTable,
			Waiter = _testWaiter
		},
		new DrinksOrdered
		{
			Id = _testId,
			Items = new List<OrderedItem> { _testDrink1 }
		},
		new DrinksServed
		{
			Id = _testId,
			MenuNumbers = new List<int> { _testDrink1.MenuNumber }
		}),
		When(new MarkDrinksServed
		{
			Id = _testId,
			MenuNumbers = new List<int> { _testDrink1.MenuNumber }
		}),
		ThenFailWith<DrinksNotOutstandingException>());
}

이 테스트를 통과 시키기 위해서는 DrinksServed 이벤트를 핸들링 하면 됩니다.

public void Apply(DrinksServed e)
{
	foreach (var num in e.MenuNumbers)
	{
		_outstandingDrinks.Remove(num);
	}
}

 

 

Closing the Tab

 

Command 와 Event 가 거의 같은 형태로 작성하는 것 처럼 보일 수는 있으나 두 가지가 거의 같을 필요는 없습니다. 이벤트가 좀 더 많은 부분에 대한 정보를 가질 수도 있고, 커맨드가 도메인 로직에 대해서 더 세분화 될 수 도 있습니다.

 

이제 최종적으로 테이블을 종료해 보겠습니다.

public class TabClosed
{
	public Guid Id;
	public decimal AmountPaid;
	public decimal OrderValue;
	public decimal TipValue;
}
public class CloseTab
{
	public Guid Id;
	public decimal AmountPaid;
}

이벤트에서는 주문한 오더에 대한 정보도 있을 수 있고, 팁에 대한 정보를 추가할 수 도 있습니다. 다만, 커맨드에서는 총 지불한 금액에 대해서만 의미가 있으므로, AmountPaid 만 넣을 수 있습니다. 물론 이 부분도 세분화 시킬 수 있겠지만, 일정 수준에서 도메인 로직을 단순화 시키는 것도 필요합니다. 중요한 개념은 이벤트는 Domain-Facing 하고, 커맨드는 User-Facing 하다는 사실입니다.

[TestMethod]
public void CanCloseTabWithTip()
{
	Test(
		Given(new TabOpened
		{
			Id = _testId,
			TableNumber = _testTable,
			Waiter = _testWaiter
		},
		new DrinksOrdered
		{
			Id = _testId,
			Items = new List<OrderedItem> { _testDrink2 }
		},
		new DrinksServed
		{
			Id = _testId,
			MenuNumbers = new List<int> { _testDrink2.MenuNumber }
		}),
		When(new CloseTab
		{
			Id = _testId,
			AmountPaid = _testDrink2.Price + 0.50M
		}),
		Then(new TabClosed
		{
			Id = _testId,
			AmountPaid = _testDrink2.Price + 0.50M,
			OrderValue = _testDrink2.Price,
			TipValue = 0.50M
		}));
}

이 테스트를 통과시키기 위해서는 주문한 내역이 계속 유지가 되어야 하고, 서빙된 시점에서 해당 Table에 가격이 적용되어야 할 것입니다. 이벤트 기반으로 작업을 하게 됐을 때의 이점 중에 하나는 도메인에 대한 이해가 쌓이면서 Aggregate에 대한 수정이 자유로워 질수 있다는 것 일텐데 주문된 메뉴번호에 대한 정보 외에도 주문된 오더에 대한 추적이 가능하게 하기 위해서 다음과 같이 수정합니다.

private List<OrderedItem> _outstandingDrinks = new List<OrderedItem>();
private List<OrderedItem> _outstandingFood = new List<OrderedItem>(); // 오더 된 food
private List<OrderedItem> _preparedFood = new List<OrderedItem>(); // 준비 된 food (조리 끝난...)

관련 이벤트와 커맨드도 수정합니다.

public void Apply(DrinksOrdered e)
{
	//_outstandingDrinks.AddRange(e.Items.Select(i => i.MenuNumber));
	_outstandingDrinks.AddRange(e.Items);
}

해당 Table에 서빙된 주문들에 대한 가격을 보존하고 있어야 하므로...

private decimal _servedItemsValue = 0M;

서빙 시에 해당 아이템을 대기리스트에서 제거하고, 가격을 더합니다.

public void Apply(DrinksServed e)
{
	//foreach (var num in e.MenuNumbers)
	//{
	//	_outstandingDrinks.Remove(num);
	//}
	foreach (var num in e.MenuNumbers)
	{
		var item = _outstandingDrinks.First(d => d.MenuNumber == num);
		_outstandingDrinks.Remove(item);
		_servedItemsValue += item.Price;
	}
}

비슷하게 Event Applier 에도 적용합니다.

최종적으로 CloseTab 커맨드를 추가합니다.

public IEnumerable Handle(CloseTab command)
{
	yield return new TabClosed
	{
		Id = command.Id,
		AmountPaid = command.AmountPaid,
		OrderValue = _servedItemsValue,
		TipValue = command.AmountPaid - _servedItemsValue
	};
}

 

 

Domain First

 

여기까지 진행하면서 눈여겨 봐야할 부분은 개발과정 자체에서 도메인에 집중해서 개발할 수 있다는 부분입니다. 또한, 실제 개발에서 쓰이는 용어 자체도 도메인에서 쓰는 단어로 변경이 가능하기 때문에, 도메인 전문가와 커뮤니케이션 하기도 쉬워집니다. 기존의 CRUD 적인 관점에서 접근하지 않고 개발이 가능하며, 도메인 전문가와 개발자간의 의사소통이 자연스러워질 수 있으며, 같은 관심사에 집중해서 개발이 가능해질 수 있습니다. 테스트 관점에서 봐도 좀 더 신속하고 명확한 테스트가 가능해지는 이점이 있습니다. 이처럼 도메인에 집중해서 개발하는 것이 굉장히 많은 이점을 제공할 수  있습니다.

 

 

최종 TabAggregate

public class TabAggregate : Aggregate,
	IHandleCommand<OpenTab>,
	IHandleCommand<PlaceOrder>,
	IHandleCommand<MarkDrinksServed>,
	IHandleCommand<MarkFoodPrepared>,
	IHandleCommand<MarkFoodServed>,
	IHandleCommand<CloseTab>,
	IApplyEvent<TabOpened>,
	IApplyEvent<DrinksOrdered>,
	IApplyEvent<DrinksServed>,
	IApplyEvent<FoodOrdered>,
	IApplyEvent<FoodPrepared>,
	IApplyEvent<FoodServed>,
	IApplyEvent<TabClosed>
{
	private bool _open = false;
	private decimal _servedItemsValue = 0M;
	private List<OrderedItem> _outstandingDrinks = new List<OrderedItem>();
	private List<OrderedItem> _outstandingFood = new List<OrderedItem>(); // 오더 된 food
	private List<OrderedItem> _preparedFood = new List<OrderedItem>(); // 준비 된 food (조리 끝난...)

	#region domain logic helper methods

	private static bool AreAllInList(List<int> want, List<OrderedItem> have)
	{
		var currentHave = new List<int>(have.Select(x => x.MenuNumber));

		foreach (var num in want)
		{
			if (currentHave.Contains(num)) currentHave.Remove(num);
			else return false;
		}

		return true;
	}

	private bool AreDrinksOutstanding(List<int> menuNumbers)
	{
		return AreAllInList(want: menuNumbers, have: _outstandingDrinks);
	}

	private bool IsFoodPrepared(List<int> menuNumbers)
	{
		return AreAllInList(want: menuNumbers, have: _preparedFood);
	}

	private bool IsFoodOutstanding(List<int> menuNumbers)
	{
		return AreAllInList(want: menuNumbers, have: _outstandingFood);
	}

	public bool HasUnservedItems()
	{
		return _outstandingDrinks.Any() || _outstandingFood.Any() || _preparedFood.Any();
	}

	#endregion

	#region command handlers

	public IEnumerable Handle(OpenTab command)
	{
		yield return new TabOpened
		{
			Id = command.Id,
			TableNumber = command.TableNumber,
			Waiter = command.Waiter
		};
	}

	public IEnumerable Handle(PlaceOrder command)
	{
		if (!_open) throw new TabNotOpenException();

		var drink = command.Items.Where(i => i.IsDrink).ToList();

		if (drink.Any())
		{
			yield return new DrinksOrdered
			{
				Id = command.Id,
				Items = drink
			};
		}

		var food = command.Items.Where(i => !i.IsDrink).ToList();

		if (food.Any())
		{
			yield return new FoodOrdered
			{
				Id = command.Id,
				Items = food
			};
		}
	}

	public IEnumerable Handle(MarkDrinksServed command)
	{
		if (!AreDrinksOutstanding(command.MenuNumbers))
			throw new DrinksNotOutstandingException();

		yield return new DrinksServed
		{
			Id = command.Id,
			MenuNumbers = command.MenuNumbers
		};
	}

	public IEnumerable Handle(MarkFoodPrepared command)
	{
		if (!IsFoodOutstanding(command.MenuNumbers))
			throw new FoodNotOutstandingException();

		yield return new FoodPrepared
		{
			Id = command.Id,
			MenuNumbers = command.MenuNumbers
		};
	}

	public IEnumerable Handle(MarkFoodServed command)
	{
		if (!IsFoodPrepared(command.MenuNumbers))
			throw new FoodNotPreparedException();

		yield return new FoodServed
		{
			Id = command.Id,
			MenuNumbers = command.MenuNumbers
		};
	}

	public IEnumerable Handle(CloseTab command)
	{
		if (!_open) throw new TabNotOpenException();
		if (HasUnservedItems()) throw new TabHasUnservedItemsException();
		if (command.AmountPaid < _servedItemsValue) throw new MustPayEnoughException();

		yield return new TabClosed
		{
			Id = command.Id,
			AmountPaid = command.AmountPaid,
			OrderValue = _servedItemsValue,
			TipValue = command.AmountPaid - _servedItemsValue
		};
	}

	#endregion

	#region event appliers

	public void Apply(TabOpened e)
	{
		_open = true;
	}

	public void Apply(DrinksOrdered e)
	{
		_outstandingDrinks.AddRange(e.Items);
	}

	public void Apply(DrinksServed e)
	{
		foreach (var num in e.MenuNumbers)
		{
			var item = _outstandingDrinks.First(d => d.MenuNumber == num);
			_outstandingDrinks.Remove(item);
			_servedItemsValue += item.Price;
		}
	}

	public void Apply(FoodOrdered e)
	{
		_outstandingFood.AddRange(e.Items);
	}

	public void Apply(FoodPrepared e)
	{
		foreach (var num in e.MenuNumbers)
		{
			var item = _outstandingFood.First(f => f.MenuNumber == num);
			_outstandingFood.Remove(item);
			_preparedFood.Add(item);
		}
	}

	public void Apply(FoodServed e)
	{
		foreach (var num in e.MenuNumbers)
		{
			var item = _preparedFood.First(f => f.MenuNumber == num);
			_preparedFood.Remove(item);
			_servedItemsValue += item.Price;
		}
	}

	public void Apply(TabClosed e)
	{
		_open = false;
	}

	#endregion
}

 

최종 TabTests
[TestClass]
public class TabTests : BDDTest<TabAggregate>
{
	private Guid _testId;
	private int _testTable;
	private string _testWaiter;
	private OrderedItem _testDrink1;
	private OrderedItem _testDrink2;
	private OrderedItem _testFood1;
	private OrderedItem _testFood2;

	[TestInitialize]
	public void Initialize()
	{
		_testId = Guid.NewGuid();
		_testTable = 42;
		_testWaiter = "Starter";

		_testDrink1 = new OrderedItem
		{
			MenuNumber = 4,
			Description = "Sprite",
			Price = 1.50M,
			IsDrink = true
		};
		_testDrink2 = new OrderedItem
		{
			MenuNumber = 10,
			Description = "Beer",
			Price = 2.50M,
			IsDrink = true
		};
		_testFood1 = new OrderedItem
		{
			MenuNumber = 16,
			Description = "Beef Noodles",
			Price = 7.50M,
			IsDrink = false
		};
		_testFood2 = new OrderedItem
		{
			MenuNumber = 25,
			Description = "Vegetable Curry",
			Price = 6.00M,
			IsDrink = false
		};
	}

	[TestMethod]
	public void CanOpenANewTab()
	{
		Test(
			Given(),
			When(new OpenTab
			{
				Id = _testId,
				TableNumber = _testTable,
				Waiter = _testWaiter
			}),
			Then(new TabOpened
			{
				Id = _testId,
				TableNumber = _testTable,
				Waiter = _testWaiter
			}));
	}

	[TestMethod]
	public void CanNotOrderWithUnopenedTab()
	{
		Test(
			Given(),
			When(new PlaceOrder
			{
				Id = _testId,
				Items = new List<OrderedItem> { _testDrink1 }
			}),
			ThenFailWith<TabNotOpenException>());
	}

	[TestMethod]
	public void CanPlaceDrinksOrder()
	{
		Test(
			Given(new TabOpened
			{
				Id = _testId,
				TableNumber = _testTable,
				Waiter = _testWaiter
			}),
			When(new PlaceOrder
			{
				Id = _testId,
				Items = new List<OrderedItem> { _testDrink1, _testDrink2 }
			}),
			Then(new DrinksOrdered
			{
				Id = _testId,
				Items = new List<OrderedItem> { _testDrink1, _testDrink2 }
			}));
	}

	[TestMethod]
	public void CanPlaceFoodOrder()
	{
		Test(
			Given(new TabOpened
			{
				Id = _testId,
				TableNumber = _testTable,
				Waiter = _testWaiter
			}),
			When(new PlaceOrder
			{
				Id = _testId,
				Items = new List<OrderedItem> { _testFood1, _testFood2 }
			}),
			Then(new FoodOrdered
			{
				Id = _testId,
				Items = new List<OrderedItem> { _testFood1, _testFood2 }
			}));
	}

	[TestMethod]
	public void CanPlaceFoodAndDrinkOrder()
	{
		Test(
			Given(new TabOpened
			{
				Id = _testId,
				TableNumber = _testTable,
				Waiter = _testWaiter
			}),
			When(new PlaceOrder
			{
				Id = _testId,
				Items = new List<OrderedItem> { _testFood1, _testDrink2 }
			}),
			Then(new DrinksOrdered
			{
				Id = _testId,
				Items = new List<OrderedItem> { _testDrink2 }
			},
			new FoodOrdered
			{
				Id = _testId,
				Items = new List<OrderedItem> { _testFood1 }
			}));
	}

	[TestMethod]
	public void OrderedDrinksCanBeServed()
	{
		Test(
			Given(new TabOpened
			{
				Id = _testId,
				TableNumber = _testTable,
				Waiter = _testWaiter
			},
			new DrinksOrdered
			{
				Id = _testId,
				Items = new List<OrderedItem> { _testDrink1, _testDrink2 }
			}),
			When(new MarkDrinksServed
			{
				Id = _testId,
				MenuNumbers = new List<int> { _testDrink1.MenuNumber, _testDrink2.MenuNumber }
			}),
			Then(new DrinksServed
			{
				Id = _testId,
				MenuNumbers = new List<int> { _testDrink1.MenuNumber, _testDrink2.MenuNumber }
			}));
	}

	[TestMethod]
	public void CanNotServeAnUnorderedDrink()
	{
		Test(
			Given(new TabOpened
			{
				Id = _testId,
				TableNumber = _testTable,
				Waiter = _testWaiter
			},
			new DrinksOrdered
			{
				Id = _testId,
				Items = new List<OrderedItem> { _testDrink1 }
			}),
			When(new MarkDrinksServed
			{
				Id = _testId,
				MenuNumbers = new List<int> { _testDrink2.MenuNumber }
			}),
			ThenFailWith<DrinksNotOutstandingException>());
	}

	[TestMethod]
	public void CanNotServeAnOrderedDrinkTwice()
	{
		Test(
			Given(new TabOpened
			{
				Id = _testId,
				TableNumber = _testTable,
				Waiter = _testWaiter
			},
			new DrinksOrdered
			{
				Id = _testId,
				Items = new List<OrderedItem> { _testDrink1 }
			},
			new DrinksServed
			{
				Id = _testId,
				MenuNumbers = new List<int> { _testDrink1.MenuNumber }
			}),
			When(new MarkDrinksServed
			{
				Id = _testId,
				MenuNumbers = new List<int> { _testDrink1.MenuNumber }
			}),
			ThenFailWith<DrinksNotOutstandingException>());
	}

	[TestMethod]
	public void CanCloseTabWithTip()
	{
		Test(
			Given(new TabOpened
			{
				Id = _testId,
				TableNumber = _testTable,
				Waiter = _testWaiter
			},
			new DrinksOrdered
			{
				Id = _testId,
				Items = new List<OrderedItem> { _testDrink2 }
			},
			new DrinksServed
			{
				Id = _testId,
				MenuNumbers = new List<int> { _testDrink2.MenuNumber }
			}),
			When(new CloseTab
			{
				Id = _testId,
				AmountPaid = _testDrink2.Price + 0.50M
			}),
			Then(new TabClosed
			{
				Id = _testId,
				AmountPaid = _testDrink2.Price + 0.50M,
				OrderValue = _testDrink2.Price,
				TipValue = 0.50M
			}));
	}

	[TestMethod]
	public void OrderedFoodCanBeMarkedPrepared()
	{
		Test(
			Given(new TabOpened
			{
				Id = _testId,
				TableNumber = _testTable,
				Waiter = _testWaiter
			},
			new FoodOrdered
			{
				Id = _testId,
				Items = new List<OrderedItem> { _testFood1, _testFood1 }
			}),
			When(new MarkFoodPrepared
			{
				Id = _testId,
				MenuNumbers = new List<int> { _testFood1.MenuNumber, _testFood1.MenuNumber }
			}),
			Then(new FoodPrepared
			{
				Id = _testId,
				MenuNumbers = new List<int> { _testFood1.MenuNumber, _testFood1.MenuNumber }
			}));
	}

	[TestMethod]
	public void FoodNotOrderedCanNotBeMarkedPrepared()
	{
		Test(
			Given(new TabOpened
			{
				Id = _testId,
				TableNumber = _testTable,
				Waiter = _testWaiter
			}),
			When(new MarkFoodPrepared
			{
				Id = _testId,
				MenuNumbers = new List<int> { _testFood2.MenuNumber }
			}),
			ThenFailWith<FoodNotOutstandingException>());
	}

	[TestMethod]
	public void CanNotMarkFoodAsPreparedTwice()
	{
		Test(
			Given(new TabOpened
			{
				Id = _testId,
				TableNumber = _testTable,
				Waiter = _testWaiter
			},
			new FoodOrdered
			{
				Id = _testId,
				Items = new List<OrderedItem> { _testFood1, _testFood1 }
			},
			new FoodPrepared
			{
				Id = _testId,
				MenuNumbers = new List<int> { _testFood1.MenuNumber, _testFood1.MenuNumber }
			}),
			When(new MarkFoodPrepared
			{
				Id = _testId,
				MenuNumbers = new List<int> { _testFood1.MenuNumber }
			}),
			ThenFailWith<FoodNotOutstandingException>());
	}

	[TestMethod]
	public void CanNotServeUnorderedFood()
	{
		Test(
			Given(new TabOpened
			{
				Id = _testId,
				TableNumber = _testTable,
				Waiter = _testWaiter
			},
			new FoodOrdered
			{
				Id = _testId,
				Items = new List<OrderedItem> { _testFood1 }
			}),
			When(new MarkFoodServed
			{
				Id = _testId,
				MenuNumbers = new List<int> { _testFood2.MenuNumber }
			}),
			ThenFailWith<FoodNotPreparedException>());
	}

	[TestMethod]
	public void CanNotServeOrderedButUnpreparedFood()
	{
		Test(
			Given(new TabOpened
			{
				Id = _testId,
				TableNumber = _testTable,
				Waiter = _testWaiter
			},
			new FoodOrdered
			{
				Id = _testId,
				Items = new List<OrderedItem> { _testFood1 }
			}),
			When(new MarkFoodServed
			{
				Id = _testId,
				MenuNumbers = new List<int> { _testFood1.MenuNumber }
			}),
			ThenFailWith<FoodNotPreparedException>());
	}

	[TestMethod]
	public void CanCloseTabByPayingExactAmmount()
	{
		Test(
			Given(new TabOpened
			{
				Id = _testId,
				TableNumber = _testTable,
				Waiter = _testWaiter
			},
			new FoodOrdered
			{
				Id = _testId,
				Items = new List<OrderedItem> { _testFood1, _testFood2 }
			},
			new FoodPrepared
			{
				Id = _testId,
				MenuNumbers = new List<int> { _testFood1.MenuNumber, _testFood2.MenuNumber }
			},
			new FoodServed
			{
				Id = _testId,
				MenuNumbers = new List<int> { _testFood2.MenuNumber, _testFood1.MenuNumber }
			}),
			When(new CloseTab
			{
				Id = _testId,
				AmountPaid = _testFood1.Price + _testFood2.Price
			}),
			Then(new TabClosed
			{
				Id = _testId,
				AmountPaid = _testFood1.Price + _testFood2.Price,
				OrderValue = _testFood1.Price + _testFood2.Price,
				TipValue = 0.00M
			}));
	}

	[TestMethod]
	public void MustPayEnoughToCloseTab()
	{
		Test(
			Given(new TabOpened
			{
				Id = _testId,
				TableNumber = _testTable,
				Waiter = _testWaiter
			},
			new DrinksOrdered
			{
				Id = _testId,
				Items = new List<OrderedItem> { _testDrink2 }
			},
			new DrinksServed
			{
				Id = _testId,
				MenuNumbers = new List<int> { _testDrink2.MenuNumber }
			}),
			When(new CloseTab
			{
				Id = _testId,
				AmountPaid = _testDrink2.Price - 0.50M
			}),
			ThenFailWith<MustPayEnoughException>());
	}

	[TestMethod]
	public void CanNotCloseTabTwice()
	{
		Test(
			Given(new TabOpened
			{
				Id = _testId,
				TableNumber = _testTable,
				Waiter = _testWaiter
			},
			new DrinksOrdered
			{
				Id = _testId,
				Items = new List<OrderedItem> { _testDrink2 }
			},
			new DrinksServed
			{
				Id = _testId,
				MenuNumbers = new List<int> { _testDrink2.MenuNumber }
			},
			new TabClosed
			{
				Id = _testId,
				AmountPaid = _testDrink2.Price + 0.50M,
				OrderValue = _testDrink2.Price,
				TipValue = 0.50M
			}),
			When(new CloseTab
			{
				Id = _testId,
				AmountPaid = _testDrink2.Price
			}),
			ThenFailWith<TabNotOpenException>());
	}

	[TestMethod]
	public void CanNotCloseTabWithUnservedDrinksItems()
	{
		Test(
			Given(new TabOpened
			{
				Id = _testId,
				TableNumber = _testTable,
				Waiter = _testWaiter
			},
			new DrinksOrdered
			{
				Id = _testId,
				Items = new List<OrderedItem> { _testDrink2 }
			}),
			When(new CloseTab
			{
				Id = _testId,
				AmountPaid = _testDrink2.Price
			}),
			ThenFailWith<TabHasUnservedItemsException>());
	}

	[TestMethod]
	public void CanNotCloseTabWithUnpreparedFoodItems()
	{
		Test(
			Given(new TabOpened
			{
				Id = _testId,
				TableNumber = _testTable,
				Waiter = _testWaiter
			},
			new FoodOrdered
			{
				Id = _testId,
				Items = new List<OrderedItem> { _testFood1 }
			}),
			When(new CloseTab
			{
				Id = _testId,
				AmountPaid = _testFood1.Price
			}),
			ThenFailWith<TabHasUnservedItemsException>());
	}

	[TestMethod]
	public void CanNotCloseTabWithUnservedFoodItems()
	{
		Test(
			Given(new TabOpened
			{
				Id = _testId,
				TableNumber = _testTable,
				Waiter = _testWaiter
			},
			new FoodOrdered
			{
				Id = _testId,
				Items = new List<OrderedItem> { _testFood1 }
			},
			new FoodPrepared
			{
				Id = _testId,
				MenuNumbers = new List<int> { _testFood1.MenuNumber }
			}),
			When(new CloseTab
			{
				Id = _testId,
				AmountPaid = _testFood1.Price
			}),
			ThenFailWith<TabHasUnservedItemsException>());
	}
}

'Framework > CQRS' 카테고리의 다른 글

Event Sourcing  (0) 2015.12.15
CQRS + Event Sourcing – A Step by Step Overview  (0) 2015.12.15
Cafe CQRS (Read Models) - part 4  (0) 2015.12.10
Cafe CQRS - part 2  (0) 2015.12.01
Cafe CQRS - part 1  (0) 2015.12.01