Elevating Solution Design with the TDD/BDD Approach: A Code Example

Kostiantyn Bilous
SharpAssembly
Published in
8 min readDec 30, 2023

--

In the dynamic world of software development, solutions' integrity, accuracy, and reliability are paramount. Embracing these qualities, Test-Driven Development (TDD), a modern software engineering methodology introduced by Kent Beck, emerges as a vital strategy for navigating complex domain challenges. This article delves into the practical application of TDD, illustrating its profound influence on the design of software solutions.

Our exploration begins with a focus on SpecFlow, an adaptation of the Cucumber language for .NET. SpecFlow stands at the intersection of technical and non-technical domains, employing a simple, domain-specific language that demystifies software behavior for all stakeholders. This tool, rooted in Behavior-Driven Development (BDD), is pivotal in translating complex software concepts into accessible terms.

We venture into a case study using SpecFlow to implement trade transaction booking and update investment portfolios via a WebAPI. This scenario will serve as a practical backdrop to demonstrate how TDD enriches solution design and underpins the rationale for employing Domain-Driven Development (DDD) and Command and Query Responsibility Segregation (CQRS) architecture patterns. Our journey through this case study aims to build a feature that meets technical and business requirements and emphasizes maintainability, scalability, and clarity.

Central to our discussion is the objective of situating business logic firmly within the Domain Layer, ensuring minimal complexity in the layers above. By the end of this article, you will have a comprehensive understanding of how TDD and BDD can be effectively applied to enhance software development processes.

Now, let's dive into our example, beginning with a SpecFlow scenario for bond purchasing and subsequent portfolio position updating.

Example (Add Trade Transaction and Update Portfolio positions)

Let's define a SpecFlow scenario for bond purchasing and subsequent updates to portfolio positions. For this scenario, we'll assume that all steps except "WHEN a user adds transactions" have already been implemented.

Scenario: User can add a new position to a portfolio
GIVEN bond with name "BOND 1" is created
AND bank account with name "BANK ACCOUNT 1" is created
WHEN a user adds transactions
| TradeDate | TradeType | BondName | Amount | Price | BankAccountName |
| 2023–01–01 | Buy | BOND 1 | 20 | 1000 | BANK ACCOUNT 1 |
THEN the positions in the portfolio on "2023–01–01" are:
| AssetName | CustodyName | Balance | CostBasisAmount |
| BOND 1 | CUST1 | 20 | 20000 |
| BANK ACCOUNT 1 | CUST1 | -20000 | 0 |

So, after defining the SpecFlow scenario, we need to implement the "WHEN a user adds transactions" step in our PortfolioControllerStepDefinitions class.

public class PortfolioControllerStepDefinitions : IClassFixture<CustomWebApplicationFactory<Program>> 
{
public PortfolioControllerStepDefinitions(CustomWebApplicationFactory<Program> factory) {
_factory = factory;
}

//...

[When(@"user adds transactions")]
public async Task WhenUserAddsTransactions(Table table) {
var tradeBondTransactionDto = table.CreateSet<TradeBondTransactionDto>();
using var client = _factory.CreateClient();

foreach (var transaction in tradeBondTransactionDto) {
var createDto = new CreateTradeBondTransactionDto {
BondId = _bond.Id,
BondPortfolioId = _bondPortfolio.Id,
BondCustodyId = _bondCustody.Id,
BankAccountId = _bankAccount.Id,
BankAccountPortfolioId = _bankAccountPortfolio.Id,
BankAccountCustodyId = _bankAccountCustody.Id,
Amount = transaction.Amount,
Price = transaction.Price,
TradeDate = transaction.TradeDate,
TradeType = transaction.TradeType
};

var content = ControllerTestHelper.SerializeToJsonStringContent(createDto);

var response = await client.PostAsync("/Bond/Trade", content);
response.EnsureSuccessStatusCode();
}
}

//...
}

When we run the test, it initially fails, indicating the absence of a POST "/Bond/Trade" route in our system. This failure is intentional in TDD, as it guides us in implementing the necessary production code to pass the test. Thus, we start by extending the BondController with a Trade method, incorporating the MediatR library to execute Domain Commands, a technique that reinforces our commitment to maintaining a CQRS architecture.

[ApiController]
[Route("[controller]")]
public class BondController : Controller
{
//...

[HttpPost("Trade")]
public async Task<IActionResult> Trade(CreateTradeBondTransactionDto createDto)
{
var command = _mapper.Map<AddTradeBondTransactionCommand>(createDto);
var result = await _mediator.Send(command);
if (result.IsFailed) return BadRequest(result.Errors);
var transaction = await _transactionRepository.GetByIdAsync(result.Value);
var transactionDto = _mapper.Map<TransactionDto>(transaction);
return CreatedAtAction(nameof(Trade), transactionDto);
}
//...
}

To align with our WebAPI structure, we then introduce a Data Transfer Object (DTO), CreateTradeBondTransactionDto, which acts as a data contract for our new POST request "/Bond/Trade":

public class CreateTradeBondTransactionDto
{
public DateTime TradeDate { get; set; }
public TradeType TradeType { get; set; }
public decimal Price { get; set; }
public decimal Amount { get; set; }

public Guid BankAccountId { get; set; }
public Guid BankAccountCustodyId { get; set; }
public Guid BankAccountPortfolioId { get; set; }

public Guid BondId { get; set; }
public Guid BondCustodyId { get; set; }
public Guid BondPortfolioId { get; set; }
}

Next, we focus on the Domain Layer, creating the AddTradeBondTransactionCommand. This command bridges our WebAPI model DTO and the internal Domain Comand contract facilitated by AutoMapper.

public class AddTradeBondTransactionCommand : IRequest<Result<Guid>>
{
public DateTime TradeDate { get; set; }
public TradeType TradeType { get; set; }
public decimal Price { get; set; }
public decimal Amount { get; set; }

public Guid BankAccountId { get; set; }
public Guid BankAccountCustodyId { get; set; }
public Guid BankAccountPortfolioId { get; set; }

public Guid BondId { get; set; }
public Guid BondCustodyId { get; set; }
public Guid BondPortfolioId { get; set; }
}

After defining our Controller method and DTO, we address the implementation of the Command Handler, AddTradeBondTransactionHandler, in the Application Layer. Here, we use a domain TransactionFactory to create a new Transaction object from the Domain Command.

public class AddTradeBondTransactionHandler : IRequestHandler<AddTradeBondTransactionCommand, Result<Guid>>
{
private readonly ILogger<AddTradeBondTransactionCommand> _logger;
private readonly IRepository<Transaction> _transactionRepository;

public AddTradeBondTransactionHandler(ILogger<AddTradeBondTransactionCommand> logger,
IRepository<Transaction> transactionRepository)
{
_logger = logger;
_transactionRepository = transactionRepository;
}

public async Task<Result<Guid>> Handle(AddTradeBondTransactionCommand request, CancellationToken cancellationToken)
{
_logger.LogInformation($"Adding buy bond transaction in InWestMan database on: {request.TradeDate}");

try
{
var transaction = TransactionFactory.CreateTradeTransaction(request);
var transactionAdded = await _transactionRepository.AddAsync(transaction, cancellationToken);
await _transactionRepository.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
$"Buy bond transaction is added in InWestMan database. Transaction id: {transactionAdded.Id}");

return Result.Ok(transactionAdded.Id);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing buy bond transaction");
return Result.Fail<Guid>(ex.Message);
}
}
}
public static class TransactionFactory
{
public static Transaction CreateTradeTransaction(AddTradeBondTransactionCommand request)
{
Transaction transaction;
switch (request.TradeType)
{
case TradeType.Buy:
transaction = new BuyBondTransaction(request.TradeDate,
request.BankAccountId,
request.BankAccountCustodyId,
request.BankAccountPortfolioId,
request.Price * request.Amount * -1,
request.BondId,
request.BondCustodyId,
request.BondPortfolioId,
request.Amount);
break;
case TradeType.Sell:
transaction = new SellBondTransaction(request.TradeDate,
request.BondId,
request.BondCustodyId,
request.BondPortfolioId,
request.Amount * -1,
request.BankAccountId,
request.BankAccountCustodyId,
request.BankAccountPortfolioId,
request.Price * request.Amount);
break;
default:
throw new ArgumentOutOfRangeException();
}

transaction.MarkAsCreated();
return transaction;
}
}

With the new Transaction added to our database, we must inform our system to recalculate portfolio positions. This is achieved by marking our Transaction as created and generating a corresponding Domain Event, EntityCreated<Transaction>. This event should be handled when the Transaction is saved to the database.

To make it work, let's delve into the Transaction domain, where we add a MarkAsCreated method to add an EntityCreated<Transaction> Domain Event to the Events collection.

public class BuyBondTransaction : Transaction
{
public BuyBondTransaction(DateTime paymentDate, Guid assetFromId, Guid custodyFromId, Guid portfolioFromId,
decimal amountFrom,
Guid assetToId, Guid custodyToId, Guid portfolioToId, decimal amountTo)
{
Type = TransactionType.BuyBond;
PaymentDate = Guard.Against.Null(paymentDate, nameof(PaymentDate));
if (amountFrom > 0) throw new ArgumentException("Bank Account payment must be negative");
if (amountTo < 0) throw new ArgumentException("Buy Bond amount must be positive");
var legShort = new Leg(assetFromId, custodyFromId, portfolioFromId, amountFrom);
var legLong = new Leg(assetToId, custodyToId, portfolioToId, amountTo);
Legs.Add(legShort);
Legs.Add(legLong);
}
}

public class SellBondTransaction : Transaction
{
public SellBondTransaction(DateTime paymentDate, Guid assetFromId, Guid custodyFromId, Guid portfolioFromId,
decimal amountFrom,
Guid assetToId, Guid custodyToId, Guid portfolioToId, decimal amountTo)
{
Type = TransactionType.SellBond;
PaymentDate = Guard.Against.Null(paymentDate, nameof(PaymentDate));
if (amountFrom > 0) throw new ArgumentException("Sell Bond amount must be negative");
if (amountTo < 0) throw new ArgumentException("Bank Account payment must be positive");
var legShort = new Leg(assetFromId, custodyFromId, portfolioFromId, amountFrom);
var legLong = new Leg(assetToId, custodyToId, portfolioToId, amountTo);
Legs.Add(legShort);
Legs.Add(legLong);
}
}

public abstract class Transaction : BaseEntity<Guid>, IAggregateRoot
{
public DateTime PaymentDate { get; set; }
public TransactionType Type { get; set; }
public ICollection<Leg> Legs { get; set; }

public void MarkAsCreated() {
Events.Add(new EntityCreated<Transaction>(this));
}
}

public abstract class BaseEntity<TId>
{
public List<BaseDomainEvent> Events = new();
public TId Id { get; set; }
}

After that, we introduce the generic Domain Event, EntityCreated<T>, for any Entity by implementing MediatR's INotification interface.

public class EntityCreated<T> : BaseDomainEvent where T : class, IAggregateRoot
{
public T Entity;
public EntityCreated(T entity)
{
Entity = entity;
}

}

public abstract class BaseDomainEvent : INotification {
}

Finally, we turn to the Infrastructure Layer. Here, we override SaveChangesAsync in AppDbContext, using the MediatR library to invoke all registered Domain Events upon saving to the database.

This approach was inspired by Ardalis’s DDD course.

public class AppDbContext : DbContext
{
private readonly IMediator _mediator;

public AppDbContext(DbContextOptions<AppDbContext> options, IMediator mediator)
: base(options)
{
_mediator = mediator;
}

//...

public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = new()) {
var entitiesWithPreSaveEvents = ChangeTracker
.Entries<BaseEntity<Guid>>()
.Where(e => e.Entity.Events != null && e.Entity.Events.Any())
.Select(e => new { Entity = e.Entity, Events = e.Entity.Events.ToArray() })
.ToList();

var result = await base.SaveChangesAsync(cancellationToken).ConfigureAwait(false);

if (_mediator == null) return result;
foreach (var entityWithEvents in entitiesWithPreSaveEvents) {
entityWithEvents.Entity.Events.Clear();
foreach (var domainEvent in entityWithEvents.Events) {
await _mediator.Publish(domainEvent, cancellationToken).ConfigureAwait(false);
}
}
return result;
}

//...
}

To complete the process, we implement the TransactionAddedHandler, which updates portfolio positions using the UpdatePositionsOnAdd method within the Portfolio entity. This handler also demonstrates the application of the Specification pattern, a topic we've explored in previous discussions.


public class TransactionAddedHandler : INotificationHandler<EntityCreated<Transaction>>
{
private readonly ILogger<EntityCreated<Transaction>> _logger;
private readonly IRepository<Portfolio> _portfolioRepository;

public TransactionAddedHandler(ILogger<EntityCreated<Transaction>> logger, IRepository<Portfolio> portfolioRepository)
{
_updatedPortfolioIds = new HashSet<Guid>();
_logger = logger;
_portfolioRepository = portfolioRepository;
}

private HashSet<Guid> _updatedPortfolioIds { get; }

public async Task Handle(EntityCreated<Transaction> notification, CancellationToken cancellationToken)
{
_logger.LogInformation($"Updating portfolios positions for transaction {notification.Entity.Id}");

foreach (var transactionLeg in notification.Entity.Legs)
{
var portfolioSpec = new SinglePortfolioByIdIncludingDependenciesSpecification(transactionLeg.PortfolioId);
var portfolio = await _portfolioRepository.SingleOrDefaultAsync(portfolioSpec, cancellationToken);
if (portfolio == null) throw new ArgumentException($"Portfolio with id {portfolio.Id} not found");

if (_updatedPortfolioIds.Contains(portfolio.Id)) continue;

_logger.LogInformation($"Updating portfolio's {portfolio.Name} positions");

portfolio.UpdatePositionsOnAdd(notification.Entity); // core logic of recalculation inside of the portfolio aggregate

await _portfolioRepository.SaveChangesAsync(cancellationToken);
_updatedPortfolioIds.Add(portfolio.Id);
_logger.LogInformation($"Portfolio's {portfolio.Name} positions are updated");
}
}
}

With these implementations in place, our initial SpecFlow test moves from FAIL to PASS. This successful transition marks the readiness for the next feature development phase, starting with defining new SpecFlow scenarios.

Conclusion

In the realm of modern software engineering, Test-Driven Development (TDD) transcends the boundaries of mere testing to embody a holistic design philosophy. Our journey in this article, starting from behavior definition via SpecFlow and progressing through a rigorous test-driven process, exemplifies how TDD cultivates functional, robust, and maintainable software aligned with precise requirements.

This methodology underscores the significance of TDD in reducing risks, elevating quality, and fostering a profound comprehension of the system. TDD champions the clean, maintainable code ethos, primed for adaptability in the face of change. Moreover, the integration of Behavior-Driven Development (BDD) complements this approach by bridging the gap between technical and non-technical stakeholders, ensuring that software behavior is comprehensible and relevant to all parties involved.

Our TDD/BDD approach has confirmed the correctness of our solution, which was crafted meticulously using Domain-Driven Development (DDD) and Command and Query Responsibility Segregation (CQRS) paradigms. We embarked on a top-to-bottom journey, commencing with SpecFlow tests, moving through Binding Steps, and then venturing into production code. This progression encompassed the definition of our Controller method, Data Transfer Objects (DTOs), Commands, Handlers, Entity Factories, Entities, Domain Events, and Domain Event Handlers, culminating in refining our data Infrastructure Layer.

This article illustrates the practical application of TDD/BDD and serves as a testament to its transformative impact on software solution design. For those seeking to delve deeper into the nuances of TDD, BDD, DDD, and CQRS and to understand their synergistic potential in software development, stay tuned for more insights and discussions.

Subscribe for more insights and in-depth analysis:

Credits: DALL·E generated

#TDD #BDD #DDD #CQRS #InWestMan

--

--

Kostiantyn Bilous
SharpAssembly

Senior Software Engineer (.Net/C#) at SimCorp, Ph.D. in Finance