Test-Driving the Architecture with Short Feedback Loops
The content here is under the Attribution 4.0 International (CC BY 4.0) license
Introduction
Test-Driven Development (TDD) has traditionally been viewed as a practice focused on code and design at the micro level, ensuring individual functions, methods, and classes work as intended (Beck, 2002). However, an often-overlooked aspect of TDD is its profound influence on architectural decisions. When developers commit to writing tests first, they inherently create a feedback mechanism that forces critical thinking about the structure and organization of their codebase (Janzen & Saiedian, 2008).
The concept of “test-driving the architecture” suggests that architectural decisions should not be made in isolation or purely based on theoretical principles. Instead, they should emerge from the constraints from the TDD cycle (Freeman & Pryce, 2009). This approach creates short feedback loops that validate architectural choices early and frequently, preventing costly rework and ensuring that the system’s structure remains aligned with its actual needs.
Research by Janzen and Saiedian demonstrates that TDD practitioners produce code with better internal quality metrics, including lower coupling and higher cohesion, both fundamental architectural properties (Janzen & Saiedian, 2005). This evidence suggests that the practice of writing tests first does more than verify correctness, it shapes the structural decisions that define system architecture.
A space dedicated for Test-Driven Development
This blog hosts a dedicated space for TDD-related content, where you can find posts that explore the concept of TDD, its benefits, and how it can be effectively implemented in software development workflows.
The Feedback Loop in TDD
At its core, TDD operates on a simple cycle: Red, Green, Refactor (Beck, 2002). This cycle creates an immediate feedback loop where:
-
Red Phase: Writing a failing test forces you to think about the interface and behavior before implementation. This phase requires articulating what the code should do from the client’s perspective, naturally pushing toward interface design that prioritizes usability.
-
Green Phase: Making the test pass provides feedback on whether your implementation meets the specified requirement. The simplest implementation that makes the test pass is preferred, deferring premature architectural complexity.
-
Refactor Phase: Improving the code while keeping tests green ensures that architectural changes don’t break existing functionality. This is where architectural patterns emerge as duplication is removed and abstractions are discovered.
Each iteration through this cycle provides feedback not just about the correctness of individual components, but about how they fit together. When tests become difficult to write or maintain, they signal architectural problems. A phenomenon Freeman and Pryce call “listening to the tests” (Freeman & Pryce, 2009). When tests are easy to write and read, they validate that the architecture supports the intended use cases.
Listeing to the tests requires intent
Recently I have faced code bases that make the tests to be hard to write, requiring many test-doubles to be set making them fragile, leading to a painful refactoring. This appeared in new tech stacks that are emerging such as server side rendering. With this new technology the bouderies of server and client are blurred, making the division of reponsabilities and the usage of test-doubles not s straight forward.
Empirical studies support this observation. George and Williams found that TDD practitioners spend more time on design activities during development, with the test-writing phase serving as a continuous design review (George & Williams, 2003). The feedback loop becomes a forcing function for architectural thinking at every step of implementation.
Architecture Emerges from Test Constraints
When practicing TDD rigorously, several architectural patterns naturally emerge from the need to write testable code. This emergence is not accidental—the constraints imposed by testability directly address well-known software design principles (Martin, 2003). The following sections explore how test-first development drives specific architectural decisions.
Dependency Injection
To test a component in isolation, you need to control its dependencies. This requirement naturally leads to dependency injection patterns, where dependencies are provided to a component rather than created within it (Feathers, 2004). This architectural decision, driven by testability, also improves modularity and flexibility. Consider a real-world example: a notification service that sends emails and logs events. Without dependency injection, testing becomes problematic:
// Hard to test - tightly coupled to concrete implementations
class NotificationService {
private emailClient: SendGridEmailClient;
private logger: CloudWatchLogger;
private database: PostgresDatabase;
constructor() {
this.emailClient = new SendGridEmailClient();
this.logger = new CloudWatchLogger();
this.database = new PostgresDatabase();
}
async notifyUser(userId: string, message: string): Promise<{ status: string }> {
const user = await this.database.getUserById(userId);
await this.emailClient.send(user.email, message);
this.logger.info(`Notification sent to user ${userId}`);
return { status: 'sent' };
}
}
When attempting to test this class, several problems emerge:
- Tests require actual SendGrid credentials and make real API calls
- Tests create real database connections and query actual data
- Tests produce side effects in CloudWatch logging
- Tests are slow due to network I/O
- Tests fail when external services are unavailable
The pain of testing drives the architectural improvement—dependency injection:
interface EmailClient {
send(email: string, message: string): Promise<boolean>;
}
interface Logger {
info(message: string): void;
}
interface Database {
getUserById(userId: string): Promise<User>;
}
interface User {
id: string;
email: string;
}
// Easy to test - dependencies injected
class NotificationService {
constructor(
private emailClient: EmailClient,
private logger: Logger,
private database: Database
) {}
async notifyUser(userId: string, message: string): Promise<{ status: string }> {
const user = await this.database.getUserById(userId);
await this.emailClient.send(user.email, message);
this.logger.info(`Notification sent to user ${userId}`);
return { status: 'sent' };
}
}
// Test with injected test doubles
describe('NotificationService', () => {
it('should send email to user and log the event', async () => {
const mockEmailClient: EmailClient = {
send: jest.fn().mockResolvedValue(true)
};
const mockLogger: Logger = {
info: jest.fn()
};
const mockDatabase: Database = {
getUserById: jest.fn().mockResolvedValue({ id: '1', email: 'user@example.com' })
};
const service = new NotificationService(mockEmailClient, mockLogger, mockDatabase);
const result = await service.notifyUser('1', 'Hello');
expect(result.status).toBe('sent');
expect(mockEmailClient.send).toHaveBeenCalledWith('user@example.com', 'Hello');
expect(mockLogger.info).toHaveBeenCalledWith('Notification sent to user 1');
});
});
This pattern is fundamental to what Martin Fowler calls the “Dependency Inversion Principle”, high-level modules should not depend on low-level modules, but both should depend on abstractions (Martin, 2003). TDD naturally enforces this principle through the concrete need to substitute implementations during testing.
Interface Segregation
When writing tests, you often need to create test doubles (mocks, stubs, fakes). The ease of creating these doubles depends on having well-defined, focused interfaces. TDD encourages breaking down large interfaces into smaller, more specific ones—a principle that also leads to better architectural design (Martin, 2003).
Consider an order processing system with a complex interface:
// Large interface - difficult to mock
interface OrderProcessor {
validateOrder(order: Order): ValidationResult;
calculateTax(order: Order): number;
applyDiscount(order: Order, couponCode: string): number;
processPayment(order: Order, paymentInfo: PaymentInfo): PaymentResult;
updateInventory(order: Order): void;
sendConfirmationEmail(order: Order): void;
generateInvoice(order: Order): Invoice;
notifyWarehouse(order: Order): void;
}
Testing a component that depends on OrderProcessor requires implementing or mocking all eight methods, even if the component only uses one or two. This testing pain signals an architectural problem—the interface violates the Interface Segregation Principle.
TDD drives the solution: split the interface into focused, cohesive units:
// Focused interfaces - easy to test
interface OrderValidator {
validate(order: Order): ValidationResult;
}
interface TaxCalculator {
calculate(order: Order): number;
}
interface DiscountApplier {
apply(order: Order, couponCode: string): number;
}
interface PaymentProcessor {
process(order: Order, paymentInfo: PaymentInfo): PaymentResult;
}
interface OrderFulfillment {
updateInventory(order: Order): void;
notifyWarehouse(order: Order): void;
}
interface OrderNotifications {
sendConfirmation(order: Order): void;
}
interface InvoiceGenerator {
generate(order: Order): Invoice;
}
Now, a component that only validates orders needs only the OrderValidator interface, making tests simpler and more focused:
class OrderSubmissionHandler {
constructor(private validator: OrderValidator) {}
async submit(order: Order): Promise<SubmissionResult> {
const validation = this.validator.validate(order);
if (!validation.isValid) {
return { success: false, errors: validation.errors };
}
return { success: true };
}
}
// Test requires only one small mock
describe('OrderSubmissionHandler', () => {
it('should reject invalid orders', async () => {
const mockValidator: OrderValidator = {
validate: (order) => ({ isValid: false, errors: ['Invalid zip code'] })
};
const handler = new OrderSubmissionHandler(mockValidator);
const result = await handler.submit(invalidOrder);
expect(result.success).toBe(false);
expect(result.errors).toContain('Invalid zip code');
});
});
This architectural improvement—driven by the need for testable code—also benefits the production system by reducing coupling, improving modularity, and allowing independent evolution of different responsibilities.
Single Responsibility Principle
A component that does too much is difficult to test comprehensively. TDD naturally encourages splitting components that have multiple responsibilities into separate, focused units (Martin, 2003). This test-driven constraint aligns perfectly with the Single Responsibility Principle, resulting in a more maintainable architecture.
The testing pain manifests in several ways:
- Excessive test setup: Tests require complex arrangements of data and state
- Fragile tests: Changes to one responsibility break tests for unrelated functionality
- Low cohesion: Test names struggle to describe what the component actually does
- Test explosion: The number of test cases grows exponentially with each new responsibility
Consider a user registration component that handles too much:
class UserRegistrationService {
registerUser(email: string, password: string, country: string): string {
// Responsibility 1: Input validation
if (!this.isValidEmail(email)) {
throw new Error("Invalid email");
}
if (password.length < 8) {
throw new Error("Password too short");
}
// Responsibility 2: Password hashing
const salt = this.generateSalt();
const hashedPassword = this.hashPassword(password, salt);
// Responsibility 3: Database operations
const userId = this.insertUser(email, hashedPassword, salt);
// Responsibility 4: Email sending
const token = this.generateVerificationToken(userId);
this.sendVerificationEmail(email, token);
// Responsibility 5: Analytics tracking
this.trackRegistrationEvent(userId, country);
// Responsibility 6: Compliance checks
if (this.isGdprCountry(country)) {
this.createGdprRecord(userId);
}
return userId;
}
private isValidEmail(email: string): boolean {
// Implementation
return email.includes('@');
}
private generateSalt(): string {
// Implementation
return Math.random().toString(36);
}
private hashPassword(password: string, salt: string): string {
// Implementation
return `hashed_${password}_${salt}`;
}
private insertUser(email: string, hashedPassword: string, salt: string): string {
// Implementation
return `user-${Date.now()}`;
}
private generateVerificationToken(userId: string): string {
// Implementation
return `token_${userId}`;
}
private sendVerificationEmail(email: string, token: string): void {
// Implementation
}
private trackRegistrationEvent(userId: string, country: string): void {
// Implementation
}
private isGdprCountry(country: string): boolean {
// Implementation
return ['DE', 'FR', 'IT'].includes(country);
}
private createGdprRecord(userId: string): void {
// Implementation
}
}
Testing this method requires:
- Mocking the database
- Mocking the email service
- Mocking the analytics service
- Setting up GDPR compliance infrastructure
- Testing password hashing behavior
- Testing all validation combinations
The test setup becomes unwieldy:
describe('UserRegistrationService', () => {
it('should register user in GDPR country', () => {
// Extensive setup required
const mockDb = jest.fn();
const mockEmail = jest.fn();
const mockAnalytics = jest.fn();
const mockGdpr = jest.fn();
const service = new UserRegistrationService();
// Need to mock all private methods
jest.spyOn(service as any, 'generateSalt').mockReturnValue("salt123");
jest.spyOn(service as any, 'hashPassword').mockReturnValue("hashed");
jest.spyOn(service as any, 'isValidEmail').mockReturnValue(true);
// ... many more mocks needed
});
});
TDD drives refactoring into focused responsibilities:
interface User {
id: string;
email: string;
passwordHash: string;
salt: string;
}
// Each class has one reason to change
class EmailValidator {
isValid(email: string): boolean {
return email.includes("@") && email.split("@")[1].includes(".");
}
}
class PasswordHasher {
hash(password: string): { hash: string; salt: string } {
const salt = this.generateSalt();
const hash = this.hashWithSalt(password, salt);
return { hash, salt };
}
private generateSalt(): string {
// Implementation
return Math.random().toString(36);
}
private hashWithSalt(password: string, salt: string): string {
// Implementation
return `hashed_${password}_${salt}`;
}
}
class UserRepository {
save(user: User): string {
// Database operations only
return this.insertUser(user);
}
private insertUser(user: User): string {
// Implementation
return `user-${Date.now()}`;
}
}
class VerificationEmailSender {
send(email: string, token: string): void {
// Email operations only
this.sendEmail(email, "Verify your account", token);
}
private sendEmail(email: string, subject: string, token: string): void {
// Implementation
}
}
class RegistrationAnalytics {
track(userId: string, country: string): void {
// Analytics only
this.trackEvent("user_registered", userId, country);
}
private trackEvent(event: string, userId: string, country: string): void {
// Implementation
}
}
class GDPRComplianceService {
ensureCompliance(userId: string, country: string): void {
if (this.isGdprCountry(country)) {
this.createComplianceRecord(userId);
}
}
private isGdprCountry(country: string): boolean {
// Implementation
return ['DE', 'FR', 'IT', 'ES', 'UK'].includes(country);
}
private createComplianceRecord(userId: string): void {
// Implementation
}
}
// Orchestrator with clear dependencies
class UserRegistrationService {
constructor(
private validator: EmailValidator,
private hasher: PasswordHasher,
private repository: UserRepository,
private emailSender: VerificationEmailSender,
private analytics: RegistrationAnalytics,
private compliance: GDPRComplianceService
) {}
register(email: string, password: string, country: string): string {
if (!this.validator.isValid(email)) {
throw new Error("Invalid email");
}
const { hash: hashedPassword, salt } = this.hasher.hash(password);
const user: User = {
id: '',
email,
passwordHash: hashedPassword,
salt
};
const userId = this.repository.save(user);
const token = this.generateToken(userId);
this.emailSender.send(email, token);
this.analytics.track(userId, country);
this.compliance.ensureCompliance(userId, country);
return userId;
}
private generateToken(userId: string): string {
return `token_${userId}`;
}
}
Now each component can be tested in isolation with minimal setup:
describe('UserRegistrationService', () => {
it('should reject invalid email format', () => {
const validator = new EmailValidator();
expect(validator.isValid("not-an-email")).toBe(false);
});
it('should generate different salts for same password', () => {
const hasher = new PasswordHasher();
const result1 = hasher.hash("password123");
const result2 = hasher.hash("password123");
expect(result1.salt).not.toBe(result2.salt); // Different salts each time
});
it('should orchestrate registration successfully', () => {
// Much simpler test setup
const mockValidator = { isValid: jest.fn().mockReturnValue(true) };
const mockHasher = { hash: jest.fn().mockReturnValue({ hash: "hashed", salt: "salt" }) };
const mockRepo = { save: jest.fn().mockReturnValue("42") };
const mockEmail = { send: jest.fn() };
const mockAnalytics = { track: jest.fn() };
const mockCompliance = { ensureCompliance: jest.fn() };
const service = new UserRegistrationService(
mockValidator as any,
mockHasher as any,
mockRepo as any,
mockEmail as any,
mockAnalytics as any,
mockCompliance as any
);
const userId = service.register("user@example.com", "password123", "DE");
expect(userId).toBe("42");
expect(mockEmail.send).toHaveBeenCalledTimes(1);
});
});
This refactoring, driven by test pain, results in a system where each component has a single, well-defined responsibility. The architecture becomes more flexible—if the password hashing algorithm needs to change, only PasswordHasher is affected. If GDPR rules change, only GDPRComplianceService needs updating.
Layered Architecture
Tests often reveal coupling between different levels of abstraction. For example, business logic tests shouldn’t depend on database implementations or external services. This feedback drives the creation of clear layers with well-defined boundaries (Martin, 2017), such as separating business logic from infrastructure concerns.
Consider an e-commerce pricing system where business rules are tangled with database access:
// Business logic tightly coupled to infrastructure
class PricingService {
constructor(private dbConnection: DatabaseConnection) {}
async calculatePrice(productId: string, customerId: string): Promise<number> {
// Database call embedded in business logic
const stmt = await this.dbConnection.prepare(
"SELECT base_price FROM products WHERE id = ?"
);
const product = await stmt.get(productId);
const basePrice = product.base_price;
// Another database call
const customerStmt = await this.dbConnection.prepare(
"SELECT loyalty_tier FROM customers WHERE id = ?"
);
const customer = await customerStmt.get(customerId);
const loyaltyTier = customer.loyalty_tier;
// Business logic
let discount: number;
switch (loyaltyTier) {
case "GOLD":
discount = 0.20;
break;
case "SILVER":
discount = 0.10;
break;
default:
discount = 0;
}
return basePrice * (1 - discount);
}
}
Testing this business logic requires:
- Setting up an actual database
- Inserting test data
- Managing transactions
- Cleaning up after tests
- Dealing with database connection issues
The tests become slow, brittle, and environment-dependent. TDD reveals this architectural flaw through test pain. The solution is to separate concerns into distinct layers:
// Define domain types
enum LoyaltyTier {
GOLD = "GOLD",
SILVER = "SILVER",
BRONZE = "BRONZE"
}
interface Product {
id: string;
basePrice: number;
}
interface Customer {
id: string;
loyaltyTier: LoyaltyTier;
}
// Domain layer - pure business logic
class PricingCalculator {
calculateDiscountedPrice(basePrice: number, tier: LoyaltyTier): number {
let discountRate: number;
switch (tier) {
case LoyaltyTier.GOLD:
discountRate = 0.20;
break;
case LoyaltyTier.SILVER:
discountRate = 0.10;
break;
case LoyaltyTier.BRONZE:
discountRate = 0;
break;
}
return basePrice * (1 - discountRate);
}
}
// Repository interfaces - abstraction over data access
interface ProductRepository {
findById(productId: string): Promise<Product | null>;
}
interface CustomerRepository {
findById(customerId: string): Promise<Customer | null>;
}
// Application service - orchestration
class PricingService {
constructor(
private calculator: PricingCalculator,
private productRepo: ProductRepository,
private customerRepo: CustomerRepository
) {}
async calculatePrice(productId: string, customerId: string): Promise<number> {
const product = await this.productRepo.findById(productId);
if (!product) {
throw new Error(`Product not found: ${productId}`);
}
const customer = await this.customerRepo.findById(customerId);
if (!customer) {
throw new Error(`Customer not found: ${customerId}`);
}
return this.calculator.calculateDiscountedPrice(
product.basePrice,
customer.loyaltyTier
);
}
}
// Infrastructure layer - database implementation
class JdbcProductRepository implements ProductRepository {
constructor(private dataSource: DataSource) {}
async findById(productId: string): Promise<Product | null> {
// JDBC implementation details
return null;
}
}
Now the business logic can be tested independently of infrastructure:
describe('PricingCalculator', () => {
it('should apply 20% discount for Gold customers', () => {
// Pure business logic test - no database needed
const calculator = new PricingCalculator();
const price = 100.00;
const discounted = calculator.calculateDiscountedPrice(price, LoyaltyTier.GOLD);
expect(discounted).toBe(80.00);
});
});
describe('PricingService', () => {
it('should orchestrate pricing calculation correctly', async () => {
// Test with in-memory test doubles
const product: Product = { id: "P1", basePrice: 100.00 };
const customer: Customer = { id: "C1", loyaltyTier: LoyaltyTier.GOLD };
const mockProductRepo: ProductRepository = {
findById: jest.fn().mockResolvedValue(product)
};
const mockCustomerRepo: CustomerRepository = {
findById: jest.fn().mockResolvedValue(customer)
};
const service = new PricingService(
new PricingCalculator(),
mockProductRepo,
mockCustomerRepo
);
const price = await service.calculatePrice("P1", "C1");
expect(price).toBe(80.00);
});
});
describe('JdbcProductRepository', () => {
it('should retrieve product from database', async () => {
// Integration test for infrastructure layer only
const testDataSource = createTestDataSource();
const repo = new JdbcProductRepository(testDataSource);
const product = await repo.findById("P1");
expect(product).not.toBeNull();
});
});
This layered architecture, driven by the need for testable code, provides several benefits:
- Fast feedback: Business logic tests run in milliseconds without database overhead
- Clear boundaries: Each layer has well-defined responsibilities
- Flexibility: Database implementation can change without affecting business logic
- Testability at each level: Unit tests for logic, integration tests for infrastructure
- Reduced coupling: Business rules don’t depend on infrastructure details
The architecture follows the Dependency Inversion Principle—high-level business logic depends on abstractions (repositories), not on low-level details (JDBC) (Martin, 2017). This inversion, driven by TDD, creates a flexible and maintainable system structure.
Short Feedback Loops Drive Architectural Decisions
Traditional architectural approaches often involve upfront design decisions that are difficult to validate until much later in the development process. By contrast, test-driving architecture creates multiple short feedback loops that provide empirical evidence about design choices (Erdogmus et al., 2005).
The feedback loop duration matters significantly. Research shows that the time between introducing a defect and discovering it correlates directly with the cost of fixing it (Bhat & Nagappan, 2006). Architectural problems discovered late in development require extensive refactoring that may span multiple modules. TDD’s short feedback cycles catch architectural issues early, when they’re localized and easier to address.
Immediate Design Validation
Each test provides immediate feedback about whether a particular design decision works in practice. If a test is painful to write, it suggests the design needs improvement (Freeman & Pryce, 2009). This rapid feedback allows developers to course-correct before investing hours or days in a problematic direction.
Consider the feedback signals tests provide:
Signal: Difficult test setup
// This test setup is painful - requires 15+ lines
describe('OrderProcessor', () => {
it('should process order', async () => {
const mockConfig = { timeout: 5000, retries: 3, endpoint: 'http://...' };
const mockCache = { get: jest.fn(), set: jest.fn(), invalidate: jest.fn() };
const mockDb = { query: jest.fn(), transaction: jest.fn() };
const mockLogger = { info: jest.fn(), error: jest.fn(), debug: jest.fn() };
const mockMetrics = { increment: jest.fn(), timing: jest.fn() };
const mockValidator = { validate: jest.fn().mockReturnValue(true) };
const mockPaymentGateway = { charge: jest.fn().mockResolvedValue({ id: '123' }) };
const mockInventory = { reserve: jest.fn(), release: jest.fn() };
const mockNotifier = { notify: jest.fn() };
const processor = new OrderProcessor(
mockConfig, mockCache, mockDb, mockLogger,
mockMetrics, mockValidator, mockPaymentGateway,
mockInventory, mockNotifier
);
// ... actual test
});
});
Feedback: Too many dependencies indicate a violation of the Single Responsibility Principle. The component needs to be split into smaller, focused units.
Signal: Tests break due to unrelated changes
// Change the date format in one place
function formatOrderDate(order: Order): string {
return order.date.toLocaleDateString('en-GB'); // Changed from ISO format
}
// Causes failures in seemingly unrelated tests
describe('Invoice', () => {
it('generates invoice', () => { /* FAILS */ });
});
describe('Order Export', () => {
it('exports orders', () => { /* FAILS */ });
});
describe('Receipt', () => {
it('prints receipt', () => { /* FAILS */ });
});
describe('Analytics', () => {
it('generates report', () => { /* FAILS */ });
});
Feedback: High coupling between components. The date formatting logic is duplicated or tightly bound to multiple features. Extract it into a shared service or use a value object pattern.
Signal: Cannot test without concrete implementations
// Cannot test without real Redis and PostgreSQL
describe('UserSession', () => {
it('should manage user session', async () => {
const redisClient = new RedisClient({ host: 'localhost', port: 6379 });
const db = new PostgresClient('postgresql://localhost:5432/test');
const sessionManager = new UserSessionManager(redisClient, db);
// Test requires running infrastructure
});
});
Feedback: Missing abstraction layer. Introduce interfaces for external dependencies to enable testing with in-memory or mock implementations.
Signal: Test becomes a reimplementation of production code
describe('Calculator', () => {
it('should perform complex calculation', () => {
const input = 10;
const calculator = new Calculator();
// Test duplicates the entire algorithm
const step1 = input * 2.5;
const step2 = step1 + Math.log(input);
const step3 = step2 / (1 + input);
const adjusted = step3 * seasonalFactor;
const final = adjusted > threshold ? adjusted : defaultValue;
expect(calculator.calculate(input)).toBe(final);
});
});
Feedback: The algorithm’s complexity is encapsulated in a single method, making it difficult to test individual steps or reason about the logic. Break it into smaller, composable functions that can be tested independently.
These feedback signals guide architectural improvements in real-time. A study by Janzen and Saiedian found that developers practicing TDD made more frequent, smaller refactorings compared to test-after approaches (Janzen & Saiedian, 2008). The constant feedback from tests creates a natural rhythm of continuous architectural refinement.
Continuous Architectural Refactoring
Just as TDD encourages refactoring code during the Refactor phase, it also supports continuous architectural refactoring (Beck, 2002). As the system grows and new patterns emerge, the comprehensive test suite provides confidence that architectural changes won’t introduce regressions.
The refactor phase of TDD is often underemphasized, yet it’s where architectural improvements solidify. Beck’s original TDD book emphasizes that the refactor step is not optional—it’s where we “remove duplication” and discover the right abstractions (Beck, 2002).
Consider an example where duplication reveals a missing architectural abstraction:
Initial implementation (three separate features)
interface Credentials {
username: string;
password: string;
}
interface AuthToken {
token: string;
userId: string;
}
// Feature 1: User login
class LoginHandler {
constructor(
private database: Database,
private cache: Cache
) {}
async login(credentials: Credentials): Promise<AuthToken> {
const user = await this.database.findUser(credentials.username);
if (!user || !this.checkPassword(credentials.password, user.passwordHash)) {
throw new Error("Invalid credentials");
}
const token = this.generateToken(user.id);
await this.cache.set(`auth_token_${user.id}`, token, 3600);
return { token, userId: user.id };
}
private checkPassword(password: string, hash: string): boolean {
// BCrypt check implementation
return true;
}
private generateToken(userId: string): string {
return `token_${userId}`;
}
}
// Feature 2: API key authentication
class ApiKeyHandler {
constructor(
private database: Database,
private cache: Cache
) {}
async authenticate(apiKey: string): Promise<AuthToken> {
const key = await this.database.findApiKey(apiKey);
if (!key || key.isRevoked) {
throw new Error("Invalid API key");
}
const token = this.generateToken(key.userId);
await this.cache.set(`auth_token_${key.userId}`, token, 3600);
return { token, userId: key.userId };
}
private generateToken(userId: string): string {
return `token_${userId}`;
}
}
// Feature 3: OAuth callback
class OAuthCallbackHandler {
constructor(
private oauthClient: OAuthClient,
private database: Database,
private cache: Cache
) {}
async handleCallback(code: string): Promise<AuthToken> {
const oauthToken = await this.oauthClient.exchangeCodeForToken(code);
const userInfo = await this.oauthClient.fetchUserInfo(oauthToken);
const user = await this.database.findOrCreateUser(userInfo);
const token = this.generateToken(user.id);
await this.cache.set(`auth_token_${user.id}`, token, 3600);
return { token, userId: user.id };
}
private generateToken(userId: string): string {
return `token_${userId}`;
}
}
Tests for these three features reveal duplication in token generation and caching logic. The refactor phase drives architectural improvement:
After refactoring (architectural pattern emerges)
// Core authentication abstraction
interface AuthenticationStrategy {
authenticate(credentials: any): Promise<User>;
}
interface User {
id: string;
username: string;
passwordHash?: string;
}
interface ApiKey {
userId: string;
isRevoked: boolean;
}
// Strategies implement specific authentication methods
class PasswordAuthStrategy implements AuthenticationStrategy {
constructor(private database: Database) {}
async authenticate(credentials: any): Promise<User> {
const creds = credentials as Credentials;
const user = await this.database.findUser(creds.username);
if (!user) {
throw new Error("User not found");
}
if (!this.checkPassword(creds.password, user.passwordHash!)) {
throw new Error("Invalid password");
}
return user;
}
private checkPassword(password: string, hash: string): boolean {
// BCrypt implementation
return true;
}
}
class ApiKeyAuthStrategy implements AuthenticationStrategy {
constructor(private database: Database) {}
async authenticate(credentials: any): Promise<User> {
const apiKey = credentials as string;
const key = await this.database.findApiKey(apiKey);
if (!key) {
throw new Error("API key not found");
}
if (key.isRevoked) {
throw new Error("API key revoked");
}
const user = await this.database.findUserById(key.userId);
if (!user) {
throw new Error("User not found");
}
return user;
}
}
class OAuthAuthStrategy implements AuthenticationStrategy {
constructor(
private oauthClient: OAuthClient,
private database: Database
) {}
async authenticate(credentials: any): Promise<User> {
const code = credentials as string;
const oauthToken = await this.oauthClient.exchangeCodeForToken(code);
const userInfo = await this.oauthClient.fetchUserInfo(oauthToken);
return await this.database.findOrCreateUser(userInfo);
}
}
// Token service handles token generation and caching (single responsibility)
class TokenService {
constructor(private cache: Cache) {}
async issueToken(userId: string): Promise<AuthToken> {
const token = this.generateSecureToken();
await this.cache.set(`auth_token_${userId}`, token, 3600);
return { token, userId };
}
private generateSecureToken(): string {
return crypto.randomUUID();
}
}
// Authentication coordinator uses Strategy pattern
enum AuthType {
PASSWORD = "PASSWORD",
API_KEY = "API_KEY",
OAUTH = "OAUTH"
}
class AuthenticationService {
constructor(
private strategies: Map<AuthType, AuthenticationStrategy>,
private tokenService: TokenService
) {}
async authenticate(type: AuthType, credentials: any): Promise<AuthToken> {
const strategy = this.strategies.get(type);
if (!strategy) {
throw new Error(`Unsupported auth type: ${type}`);
}
const user = await strategy.authenticate(credentials);
return await this.tokenService.issueToken(user.id);
}
}
The refactored architecture provides several improvements:
- Strategy Pattern: Different authentication methods are encapsulated in separate strategies
- Single Responsibility: Token generation is isolated in
TokenService - Open/Closed Principle: New authentication methods can be added without modifying existing code
- Testability: Each strategy can be tested independently
// Tests become simpler and more focused
describe('PasswordAuthStrategy', () => {
it('should authenticate valid credentials', async () => {
const mockDb: Database = {
findUser: jest.fn().mockResolvedValue({
id: 'user123',
username: 'john',
passwordHash: 'hashed_password'
}),
findApiKey: jest.fn(),
findUserById: jest.fn(),
findOrCreateUser: jest.fn()
};
const strategy = new PasswordAuthStrategy(mockDb);
const result = await strategy.authenticate({
username: 'john',
password: 'password'
});
expect(result.id).toBe('user123');
});
});
describe('TokenService', () => {
it('should cache issued token', async () => {
const mockCache: Cache = {
set: jest.fn(),
get: jest.fn()
};
const service = new TokenService(mockCache);
const token = await service.issueToken('user123');
expect(mockCache.set).toHaveBeenCalledWith(
expect.stringContaining('auth_token_'),
expect.any(String),
3600
);
});
});
This architectural refactoring—removing duplication and extracting abstractions—happens continuously in TDD. Each cycle provides an opportunity to improve the structure. The tests provide a safety net that makes architectural changes less risky.
Research by Rafique and Mišić found that TDD practitioners perform refactoring more frequently, leading to better internal quality metrics over time (Rafique & Mišić, 2013). The continuous nature of architectural improvement in TDD contrasts sharply with “big bang” architectural refactorings that carry substantial risk.
Documentation Through Tests
Tests serve as living documentation of how the system’s components are intended to work together (Freeman & Pryce, 2009). This documentation is particularly valuable for architectural decisions, as it captures not just what the architecture is, but how it should be used and what guarantees it provides.
Unlike written documentation that often becomes outdated, test documentation remains synchronized with the codebase—tests that don’t match the implementation fail. This property makes tests uniquely valuable for communicating architectural intent.
Example: Architecture documented through tests
Consider a message processing system with specific architectural guarantees:
enum ProcessingStatus {
COMPLETED = "COMPLETED",
DUPLICATE = "DUPLICATE",
FAILED = "FAILED"
}
enum AckStatus {
ACCEPTED = "ACCEPTED"
}
interface Message {
id: string;
content: string;
}
interface ProcessingResult {
status: ProcessingStatus;
errorCode?: string;
}
interface Acknowledgment {
status: AckStatus;
}
/
* Documents the architectural guarantees of the message processing system.
*
* Architecture rules:
* 1. Messages are processed exactly once (idempotency)
* 2. Failed messages go to dead letter queue after 3 retries
* 3. Processing is asynchronous and non-blocking
* 4. System supports at least 1000 messages/second throughput
*/
describe('MessageProcessingArchitecture', () => {
/
* Architectural guarantee: Duplicate messages are handled safely.
*
* This test documents that the system uses message deduplication
* based on message IDs. Even if the same message is received multiple
* times (due to network retries), it will only be processed once.
*/
it('should process messages idempotently', async () => {
const processor = new MessageProcessor(new InMemoryDeduplicator());
const message: Message = { id: "msg-123", content: "Process this" };
const result1 = await processor.process(message);
const result2 = await processor.process(message); // Duplicate
expect(result1.status).toBe(ProcessingStatus.COMPLETED);
expect(result2.status).toBe(ProcessingStatus.DUPLICATE);
expect(processor.getProcessingCount("msg-123")).toBe(1);
});
/
* Architectural guarantee: Poison messages don't block the system.
*
* Documents the retry policy: 3 attempts with exponential backoff,
* then move to DLQ for manual investigation.
*/
it('should move failed messages to dead letter queue', async () => {
const dlq = new InMemoryDeadLetterQueue();
const processor = new MessageProcessor({
handler: new AlwaysFailingHandler(),
retryPolicy: new RetryPolicy(3, 'exponential'),
deadLetterQueue: dlq
});
const message: Message = { id: "msg-456", content: "Bad message" };
await processor.process(message);
expect(processor.getRetryCount("msg-456")).toBe(3);
expect(dlq.contains("msg-456")).toBe(true);
expect(dlq.getFailureReason("msg-456")).toBe("ProcessingError: Handler failed");
});
/
* Architectural guarantee: Message submission is non-blocking.
*
* Documents that message processing happens asynchronously.
* The submission call returns immediately with an acknowledgment,
* while actual processing occurs in the background.
*/
it('should not block caller during async processing', async () => {
const processor = new MessageProcessor(new ThreadPoolExecutor());
const message: Message = { id: "msg-789", content: "Long running task" };
const startTime = Date.now();
const ack = await processor.submit(message);
const submissionTime = Date.now() - startTime;
expect(ack.status).toBe(AckStatus.ACCEPTED);
expect(submissionTime).toBeLessThan(100); // Submits in under 100ms
// Wait for actual processing to complete
const result = await processor.waitForCompletion("msg-789", 5000);
expect(result.status).toBe(ProcessingStatus.COMPLETED);
});
/
* Architectural guarantee: System handles required load.
*
* Documents the performance characteristic that the system
* must process at least 1000 messages per second.
*/
it('should meet throughput requirements', async () => {
const processor = new MessageProcessor();
const messages: Message[] = Array.from({ length: 1000 }, (_, i) => ({
id: `msg-${i}`,
content: `Message ${i}`
}));
const startTime = Date.now();
for (const msg of messages) {
await processor.submit(msg);
}
for (const msg of messages) {
await processor.waitForCompletion(msg.id, 2000);
}
const elapsedTime = (Date.now() - startTime) / 1000;
const throughput = messages.length / elapsedTime;
expect(throughput).toBeGreaterThanOrEqual(1000);
}, 30000); // 30 second timeout for performance test
});
These tests document several architectural properties:
- Idempotency: The system handles duplicate messages safely
- Fault tolerance: Failed messages don’t crash the system
- Async architecture: Processing doesn’t block callers
- Performance guarantees: System meets throughput requirements
A new team member can read these tests to understand the system’s architectural characteristics. Moreover, if someone attempts to modify the system in a way that violates these guarantees (e.g., making processing synchronous), the tests will fail, preventing architectural degradation.
Example: API contract documentation through tests
describe('Payment Gateway API - Architectural Contract', () => {
/
* Documents the idempotency guarantee of the payment API.
*
* Architectural decision: All payment operations accept an
* idempotency key to prevent duplicate charges during retries.
*/
it('should prevent duplicate charges using idempotency keys', async () => {
const gateway = new PaymentGateway();
const payment = {
amount: 100.00,
currency: 'USD',
idempotencyKey: 'order-12345-payment-1'
};
const result1 = await gateway.charge(payment);
const result2 = await gateway.charge(payment); // Retry with same key
expect(result1.transactionId).toBe(result2.transactionId);
expect(result1.status).toBe('success');
expect(result2.status).toBe('success');
// Only one actual charge occurred
expect(await gateway.getTotalCharged()).toBe(100.00);
});
/
* Documents the timeout and retry architecture.
*
* Architectural decision: Operations timeout after 30 seconds.
* Clients should implement exponential backoff for retries.
*/
it('should timeout after 30 seconds', async () => {
const gateway = new PaymentGateway({ timeout: 30000 });
const slowPaymentProvider = new SimulatedSlowProvider(35000); // 35 second delay
gateway.setProvider(slowPaymentProvider);
await expect(gateway.charge({ amount: 50.00 }))
.rejects
.toThrow(TimeoutError);
});
/
* Documents the error handling architecture.
*
* Architectural decision: All errors include a machine-readable
* error code and a human-readable message for proper error handling
* and observability.
*/
it('should return structured errors with error codes', async () => {
const gateway = new PaymentGateway();
try {
await gateway.charge({ amount: -10.00 }); // Invalid amount
fail('Should have thrown an error');
} catch (error) {
expect(error.code).toBe('INVALID_AMOUNT');
expect(error.message).toBeTruthy();
expect(error.retriable).toBe(false);
}
});
});
This approach to documentation has several advantages:
- Always up-to-date: Tests fail if they don’t match reality
- Executable: Documentation is verified on every build
- Specific: Shows actual usage patterns, not abstract descriptions
- Discoverable: Developers naturally read tests when learning the codebase
Freeman and Pryce emphasize this in “Growing Object-Oriented Software, Guided by Tests,” arguing that tests are the best place to document architectural decisions because they demonstrate the intended usage through concrete examples (Freeman & Pryce, 2009).
Practical Approaches to Test-Driving Architecture
Moving from theory to practice requires concrete strategies for using TDD to drive architectural decisions. The following approaches, drawn from both research and professional experience, provide actionable guidance for test-driving architecture.
Start with Use Cases
Rather than designing the entire architecture upfront, start with concrete use cases and test-drive their implementation (Freeman & Pryce, 2009). The architecture will emerge from the patterns and abstractions needed to support these use cases. This approach, often called “walking skeleton” or “tracer bullet” development, establishes end-to-end functionality early and allows the architecture to grow organically.
Example: Building an e-commerce checkout system
Instead of designing all components upfront (inventory, payment, shipping, notifications, analytics), start with the simplest end-to-end use case:
Feature: Customer completes purchase
Scenario: Customer buys a single item with credit card
Given a product "Widget" is in stock with price $19.99
When customer adds "Widget" to cart
And customer enters shipping address "123 Main St, City, State, 12345"
And customer enters credit card "4111111111111111"
And customer clicks "Complete Purchase"
Then the order is confirmed
And customer receives confirmation email
Test-drive this scenario first, implementing only what’s needed:
interface Product {
id: string;
name: string;
price: number;
stock: number;
}
interface Address {
street: string;
city: string;
state: string;
zipCode: string;
}
interface CreditCard {
number: string;
}
enum OrderStatus {
CONFIRMED = "CONFIRMED",
FAILED = "FAILED"
}
interface OrderResult {
status: OrderStatus;
orderId?: string;
confirmationEmailSent?: boolean;
reason?: string;
}
describe('Checkout', () => {
it('should complete simple purchase', async () => {
// Arrange - minimal setup
const catalog = new ProductCatalog();
catalog.addProduct(
new Product("widget-1", "Widget", 19.99, 10)
);
const cart = new ShoppingCart();
const checkout = new CheckoutService(catalog);
// Act - follow the use case
cart.addItem("widget-1", 1);
const orderRequest: OrderRequest = {
cart: cart,
shippingAddress: new Address("123 Main St", "City", "State", "12345"),
paymentMethod: new CreditCard("4111111111111111")
};
const result = await checkout.completePurchase(orderRequest);
// Assert - verify the use case succeeded
expect(result.status).toBe(OrderStatus.CONFIRMED);
expect(result.orderId).toBeDefined();
expect(result.confirmationEmailSent).toBe(true);
});
});
Initially, the implementation might be simple and monolithic:
interface CartItem {
productId: string;
quantity: number;
}
interface OrderRequest {
cart: ShoppingCart;
shippingAddress: Address;
paymentMethod: CreditCard;
}
class CheckoutService {
constructor(private catalog: ProductCatalog) {}
async completePurchase(orderRequest: OrderRequest): Promise<OrderResult> {
// Initial simple implementation - everything inline
const items = orderRequest.cart.getItems();
const product = await this.catalog.getProduct(items[0].productId);
if (product.stock < items[0].quantity) {
return {
status: OrderStatus.FAILED,
reason: "Out of stock"
};
}
// Process payment (stub for now)
const paymentOk = true;
if (!paymentOk) {
return {
status: OrderStatus.FAILED,
reason: "Payment declined"
};
}
// Create order
const orderId = `ORDER-${crypto.randomUUID()}`;
// Send email (stub for now)
console.log(`Sending confirmation to ${orderRequest.shippingAddress}`);
return {
status: OrderStatus.CONFIRMED,
orderId: orderId,
confirmationEmailSent: true
};
}
}
As more use cases are added (multiple items, different payment methods, gift wrapping, promotional codes), the tests will guide architectural refinements:
describe('Additional checkout scenarios', () => {
it('should purchase with promotional code', async () => {
// This test reveals the need for a pricing calculator
});
it('should purchase with PayPal', async () => {
// This test reveals the need for a payment strategy pattern
});
it('should handle low inventory scenario', async () => {
// This test reveals the need for proper inventory management
});
it('should not reduce inventory on purchase failure', async () => {
// This test reveals the need for transactional behavior
});
});
Each new test drives a specific architectural decision based on actual requirements, not speculation. The architecture emerges incrementally, validated at each step by tests that represent real use cases.
Listen to Test Pain
Pay attention to when tests become difficult to write or maintain (Freeman & Pryce, 2009). Common signals include:
- Tests that require extensive setup
interface Sale {
product: string;
quantity: number;
unitPrice: number;
date: Date;
}
interface MonthlyReport {
totalSales: number;
}
// 50 lines of setup for a simple test indicates architectural problems
describe('ReportGenerator', () => {
it('should generate monthly sales report', async () => {
// Setup database
const db = new Database('test_db');
await db.createTables();
await db.insert('products', { id: 1, name: 'Widget', price: 19.99 });
await db.insert('products', { id: 2, name: 'Gadget', price: 29.99 });
// Setup users
await db.insert('users', { id: 1, email: 'user1@example.com' });
await db.insert('users', { id: 2, email: 'user2@example.com' });
// Setup orders
await db.insert('orders', { id: 1, userId: 1, productId: 1, quantity: 2, date: '2024-01-15' });
await db.insert('orders', { id: 2, userId: 1, productId: 2, quantity: 1, date: '2024-01-20' });
// ... 20 more lines of setup
const generator = new ReportGenerator(db);
const report = await generator.generateMonthlySales(1, 2024);
expect(report.totalSales).toBe(69.97);
});
});
Test pain signals: Too many dependencies, unclear what data is relevant, fragile test that breaks with schema changes.
Architectural solution: Introduce test builders and separate data access from business logic:
describe('ReportGenerator', () => {
it('should generate monthly sales report', async () => {
// Use test builder for clear, minimal setup
const salesData: Sale[] = [
{ product: 'Widget', quantity: 2, unitPrice: 19.99, date: new Date(2024, 0, 15) },
{ product: 'Gadget', quantity: 1, unitPrice: 29.99, date: new Date(2024, 0, 20) }
];
const salesRepository = new InMemorySalesRepository(salesData);
const generator = new ReportGenerator(salesRepository);
const report = await generator.generateMonthlySales(1, 2024);
expect(report.totalSales).toBe(69.97);
});
});
- Tests that break frequently due to unrelated changes
When changing the email notification format breaks payment processing tests, it indicates tight coupling. The solution: introduce clear boundaries and interfaces between subsystems.
- Difficulty creating test doubles for dependencies
If mocking a dependency requires dozens of method stubs, the interface is too large. Apply Interface Segregation Principle to create focused interfaces.
- Tests that need to access internal implementation details
interface Item {
name: string;
price: number;
}
// Test accesses private fields - architectural smell
describe('OrderProcessor', () => {
it('should process order', async () => {
const order = new Order();
order.addItem({ name: "widget", price: 19.99 });
const processor = new OrderProcessor();
await processor.process(order);
// Forced to access internal state (anti-pattern in TypeScript)
const processedOrders = (processor as any).processedOrders;
expect(processedOrders).toContain(order);
});
});
Test pain signals: The class doesn’t expose its behavior through its public interface.
Architectural solution: Provide observable behavior through the public API:
interface ProcessingResult {
successful: boolean;
orderId?: string;
}
describe('OrderProcessor', () => {
it('should process order', async () => {
const order = new Order();
order.addItem({ name: "widget", price: 19.99 });
const processor = new OrderProcessor();
const result = await processor.process(order);
// Test through public interface
expect(result.successful).toBe(true);
expect(result.orderId).toBe("ORDER-123");
});
});
The concept of “listening to the tests” is central to test-driven architecture. Freeman and Pryce dedicate significant discussion to recognizing and responding to test pain as a design feedback mechanism (Freeman & Pryce, 2009). Tests don’t just verify behavior—they critique the design.
Use Test Boundaries to Define Architectural Boundaries
The natural boundaries between different test suites often reveal appropriate architectural boundaries (Martin, 2017). Test organization reflects and enforces system structure:
Unit tests reveal boundaries between individual components
Unit tests exercise single classes or functions in isolation. When a unit test requires mocking five dependencies, it suggests the component has too many responsibilities or is at the wrong level of abstraction.
tests/unit/
├── domain/
│ ├── test_order_validator.py # Pure business logic, no dependencies
│ ├── test_pricing_calculator.py # Pure calculations
│ └── test_inventory_checker.py # Business rules
├── services/
│ ├── test_order_service.py # Orchestration, mocks repositories
│ └── test_notification_service.py # Mocks email client
The unit test structure reveals architectural layers: domain/ contains pure business logic with no external dependencies, while services/ contains application logic that coordinates domain objects and infrastructure.
Integration tests reveal boundaries between layers or subsystems
Integration tests verify that components work together correctly across architectural boundaries. They test the integration points without testing through the entire stack:
tests/integration/
├── test_database_repositories.py # Data access layer with real DB
├── test_email_gateway.py # Email infrastructure with test SMTP
├── test_payment_gateway_integration.py # External payment API
└── test_cache_layer.py # Redis/Memcached integration
These tests validate architectural decisions about how layers communicate. For example, testing that repositories correctly translate between domain models and database schemas verifies the boundary between domain and persistence layers.
End-to-end tests reveal boundaries between the system and external dependencies
tests/e2e/
├── test_checkout_flow.py # Complete user journey
├── test_order_fulfillment_flow.py # System boundary to warehouse
└── test_payment_processing_flow.py # System boundary to payment provider
E2E tests define the system boundary and validate that the architecture correctly handles external integrations.
Example: Test structure driving hexagonal architecture
# Test structure that naturally emerges
tests/
├── unit/
│ ├── domain/ # Core business logic (Hexagon center)
│ │ ├── test_account.py
│ │ ├── test_transaction.py
│ │ └── test_balance_calculator.py
│ ├── ports/ # Interfaces (Hexagon edges)
│ │ ├── test_account_repository.py
│ │ └── test_notification_port.py
│ └── adapters/ # Implementations (Outside hexagon)
│ ├── test_postgres_account_repository.py
│ └── test_email_notification_adapter.py
├── integration/
│ ├── test_postgres_integration.py
│ └── test_email_integration.py
└── e2e/
└── test_account_management_flow.py
This test organization doesn’t just reflect the architecture—it enforces it. If a developer tries to put database code in the domain layer, they’ll find it difficult to write unit tests that run fast without database setup. The test structure guides correct placement of code.
The principle here is that test organization should match and reinforce architectural organization. If you have a layered architecture, your tests should be organized by layer. If you have a modular architecture, your tests should be organized by module. The test structure serves as a continuous check that the intended architecture is being maintained.
Embrace Emergent Design
Trust that good architecture will emerge from consistently following TDD practices (Beck, 2002; Freeman & Pryce, 2009). This doesn’t mean having no architectural vision, but rather allowing that vision to be validated and refined through continuous feedback from tests.
The concept of emergent design is often misunderstood as “no design” or “chaotic design.” In reality, emergent design is disciplined and intentional—it’s about making design decisions at the last responsible moment, when you have the most information (Beck & others, 2001).
Principles of emergent design in TDD:
- Simple Design First: Start with the simplest design that makes the tests pass. Don’t add complexity for hypothetical future requirements.
- Refactor Toward Patterns: As tests accumulate and reveal duplication or complexity, refactor toward well-known patterns. Patterns emerge; they aren’t imposed upfront.
- YAGNI (You Aren’t Gonna Need It): Don’t build architectural elements until tests demonstrate their necessity.
- Incremental Architecture: Build architecture incrementally as tests reveal the need for abstractions.
Example: Architecture emerging through TDD cycles
Cycle 1: Implement simplest solution
interface Result {
status: string;
errorCode?: string;
transactionId?: string;
}
// Test: Process a simple payment
describe('PaymentProcessor', () => {
it('should process payment', () => {
const processor = new PaymentProcessor();
const result = processor.process(100.00, "USD");
expect(result.status).toBe("success");
});
});
// Simple implementation - no architecture yet
class PaymentProcessor {
process(amount: number, currency: string): Result {
// Hardcoded logic
if (amount > 0) {
return { status: "success" };
}
return { status: "failed" };
}
}
Cycle 2: New test reveals need for validation
// Test: Reject negative amounts
describe('PaymentProcessor', () => {
it('should reject negative amount', () => {
const processor = new PaymentProcessor();
const result = processor.process(-50.00, "USD");
expect(result.errorCode).toBe("invalid_amount");
});
});
// Refactor: Extract validation (architectural element emerges)
class AmountValidator {
validate(amount: number): Error | null {
if (amount <= 0) {
return new Error("invalid_amount");
}
return null;
}
}
class PaymentProcessor {
private validator = new AmountValidator();
process(amount: number, currency: string): Result {
const err = this.validator.validate(amount);
if (err) {
return { status: "failed", errorCode: err.message };
}
return { status: "success" };
}
}
Cycle 3: Another test reveals need for multiple payment methods
interface PaymentGateway {
charge(amount: number, currency: string): Promise<{ transactionId: string }>;
}
// Test: Process PayPal payment
describe('PaymentProcessor with PayPal', () => {
it('should process PayPal payment', async () => {
const processor = new PaymentProcessor(
new AmountValidator(),
new PayPalGateway()
);
const result = await processor.process(100.00, "USD");
expect(result.status).toBe("success");
});
});
// Test: Process Stripe payment
describe('PaymentProcessor with Stripe', () => {
it('should process Stripe payment', async () => {
const processor = new PaymentProcessor(
new AmountValidator(),
new StripeGateway()
);
const result = await processor.process(100.00, "USD");
expect(result.status).toBe("success");
});
});
// Refactor: Strategy pattern emerges from test needs
class PayPalGateway implements PaymentGateway {
async charge(amount: number, currency: string): Promise<{ transactionId: string }> {
// PayPal-specific implementation
return { transactionId: "pp_transaction_123" };
}
}
class StripeGateway implements PaymentGateway {
async charge(amount: number, currency: string): Promise<{ transactionId: string }> {
// Stripe-specific implementation
return { transactionId: "ch_123" };
}
}
class PaymentProcessor {
constructor(
private validator: AmountValidator,
private gateway: PaymentGateway // Abstraction emerged from tests
) {}
async process(amount: number, currency: string): Promise<Result> {
const err = this.validator.validate(amount);
if (err) {
return { status: "failed", errorCode: err.message };
}
try {
const response = await this.gateway.charge(amount, currency);
return {
status: "success",
transactionId: response.transactionId
};
} catch (error) {
return { status: "failed", errorCode: "gateway_error" };
}
}
}
Cycle 4: Test for retry logic reveals need for resilience
interface RetryPolicy {
maxAttempts: number;
backoff: number;
}
class MockGateway implements PaymentGateway {
failures: number;
attemptCount: number = 0;
constructor(failures: number) {
this.failures = failures;
}
async charge(amount: number, currency: string): Promise<{ transactionId: string }> {
this.attemptCount++;
if (this.attemptCount <= this.failures) {
throw new Error("Gateway temporarily unavailable");
}
return { transactionId: "success_123" };
}
}
// Test: Retry failed payments
describe('PaymentProcessor with retries', () => {
it('should retry failed payment', async () => {
const failingGateway = new MockGateway(2); // Fails twice, succeeds third time
const processor = new PaymentProcessor(
new AmountValidator(),
failingGateway,
{ maxAttempts: 3, backoff: 100 }
);
const result = await processor.process(100.00, "USD");
expect(result.status).toBe("success");
expect(failingGateway.attemptCount).toBe(3);
});
});
// Refactor: Retry policy emerges
class PaymentProcessor {
constructor(
private validator: AmountValidator,
private gateway: PaymentGateway,
private retryPolicy: RetryPolicy // New architectural element
) {}
async process(amount: number, currency: string): Promise<Result> {
const err = this.validator.validate(amount);
if (err) {
return { status: "failed", errorCode: err.message };
}
let lastError: Error | null = null;
for (let attempt = 1; attempt <= this.retryPolicy.maxAttempts; attempt++) {
try {
const response = await this.gateway.charge(amount, currency);
return {
status: "success",
transactionId: response.transactionId
};
} catch (error) {
lastError = error as Error;
if (attempt < this.retryPolicy.maxAttempts) {
await this.sleep(this.retryPolicy.backoff * attempt);
}
}
}
return {
status: "failed",
errorCode: "max_retries_exceeded"
};
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
Through four TDD cycles, the architecture emerged:
- Validation extracted when tests revealed invalid input scenarios
- Strategy pattern emerged when tests required multiple payment providers
- Retry policy emerged when tests required resilience
At no point was the entire architecture designed upfront. Each element appeared when tests demonstrated its necessity. This is emergent design—intentional, disciplined, and test-driven.
Research by Janzen and Saiedian found that architectures developed through TDD exhibit better internal quality metrics than those designed upfront, suggesting that emergent design guided by tests produces sound architectural decisions (Janzen & Saiedian, 2005).
Challenges and Considerations
While test-driving architecture offers numerous benefits, it’s important to acknowledge the challenges and limitations. Understanding these helps practitioners apply TDD effectively without falling into common traps.
Balancing Emergence with Vision
While TDD can drive architectural decisions, it’s important to maintain some architectural vision (Martin, 2017). Pure emergence without guidance can lead to suboptimal patterns. The key is to use TDD to validate and refine architectural ideas rather than replacing architectural thinking entirely.
The Problem: Purely emergent design can result in:
- Inconsistent patterns across the codebase
- Missed opportunities for reusable abstractions
- Architectural decisions that don’t align with system-wide quality attributes (scalability, security, performance)
The Balance: Maintain lightweight architectural guidance while allowing tests to validate decisions:
Architectural Vision → Tests Validate → Implementation → Refine Vision
↑ ↓
└──────────────────────────────────────────────────────┘
Example: Building a multi-tenant SaaS application
Architectural vision: “Data must be isolated by tenant for security and compliance.”
This vision establishes a constraint, but TDD determines how to implement it:
interface Tenant {
id: string;
}
interface User {
id: string;
tenant: Tenant;
}
// Test drives the implementation details
describe('Tenant isolation', () => {
it('should prevent user from accessing other tenant data', async () => {
const tenantA: Tenant = { id: "tenant-a" };
const tenantB: Tenant = { id: "tenant-b" };
const userA: User = { id: "user-1", tenant: tenantA };
const repository = new UserRepository();
// Architecture ensures tenant isolation
const users = await repository.findAll(userA);
// Should only return users from tenant-a
expect(users.every(u => u.tenant.id === "tenant-a")).toBe(true);
expect(users.some(u => u.tenant.id === "tenant-b")).toBe(false);
});
});
The architectural vision (tenant isolation) provides direction, but tests validate that the implementation actually achieves it. The vision doesn’t prescribe whether to use row-level security, separate schemas, or separate databases—those details emerge through test-driven implementation.
Research by Causevic et al. found that successful TDD adoption requires balancing emergent design with architectural planning, particularly for non-functional requirements like security and performance (Causevic et al., 2011).
The Risk of Local Optima
Focusing too narrowly on making individual tests pass can sometimes lead to local optimizations that hurt the overall architecture (Janzen & Saiedian, 2008). Periodic reflection and refactoring sessions help ensure the architecture remains coherent at a system level.
The Problem: Optimizing for one test at a time can create:
- Duplication across similar components
- Inconsistent abstractions
- Missing higher-level patterns
Example: Local optimization problem
// Test 1: Email notification
describe('EmailNotifier', () => {
it('should send welcome email', async () => {
const notifier = new EmailNotifier();
await notifier.sendWelcome("user@example.com");
// Assert email sent
});
});
class EmailNotifier {
async sendWelcome(email: string): Promise<void> {
// Optimized for this specific test
// Note: SMTP is an external email service (implementation not shown)
await SMTP.send({
to: email,
subject: "Welcome!",
body: "Welcome to our service!"
});
}
}
// Test 2: SMS notification (developed separately)
describe('SMSNotifier', () => {
it('should send welcome SMS', async () => {
const notifier = new SMSNotifier();
await notifier.sendWelcome("+1234567890");
// Assert SMS sent
});
});
class SMSNotifier {
async sendWelcome(phone: string): Promise<void> {
// Optimized for this specific test, duplicating structure
// Note: TwilioAPI is an external SMS service (implementation not shown)
await TwilioAPI.send({
to: phone,
message: "Welcome to our service!"
});
}
}
// Test 3: Push notification (developed separately again)
describe('PushNotifier', () => {
it('should send welcome push notification', async () => {
const notifier = new PushNotifier();
await notifier.sendWelcome("device-token-123");
// Assert push sent
});
});
class PushNotifier {
async sendWelcome(deviceToken: string): Promise<void> {
// Same pattern again - local optimum
// Note: FirebaseAPI is an external push notification service (implementation not shown)
await FirebaseAPI.send({
to: deviceToken,
title: "Welcome!",
body: "Welcome to our service!"
});
}
}
Each class passes its tests, but the architecture has missed the opportunity for a unifying abstraction. The solution requires stepping back:
Global optimization through refactoring:
interface NotificationChannel {
send(recipient: string, template: WelcomeTemplate): Promise<void>;
}
interface WelcomeTemplate {
subject: string;
body: string;
}
interface UserWithContact {
contactInfoFor(channel: NotificationChannel): string;
}
// Recognize the pattern across tests
describe('NotificationService', () => {
it('should send welcome via email', async () => {
const service = new NotificationService(new EmailChannel());
const user = createUser({ email: "user@example.com" });
await service.sendWelcome(user);
// Assert notification sent via email
});
it('should send welcome via SMS', async () => {
const service = new NotificationService(new SMSChannel());
const user = createUser({ phone: "+1234567890" });
await service.sendWelcome(user);
// Assert notification sent via SMS
});
it('should send welcome via push', async () => {
const service = new NotificationService(new PushChannel());
const user = createUser({ deviceToken: "device-token-123" });
await service.sendWelcome(user);
// Assert notification sent via push
});
});
// Unified architecture emerges from recognizing the pattern
class NotificationService {
constructor(private channel: NotificationChannel) {}
async sendWelcome(user: UserWithContact): Promise<void> {
await this.channel.send(
user.contactInfoFor(this.channel),
new WelcomeTemplate()
);
}
}
// Strategy pattern provides extensibility
class EmailChannel implements NotificationChannel {
async send(recipient: string, template: WelcomeTemplate): Promise<void> {
await SMTP.send({
to: recipient,
subject: template.subject,
body: template.body
});
}
}
class SMSChannel implements NotificationChannel {
async send(recipient: string, template: WelcomeTemplate): Promise<void> {
await TwilioAPI.send({
to: recipient,
message: template.body
});
}
}
class PushChannel implements NotificationChannel {
async send(recipient: string, template: WelcomeTemplate): Promise<void> {
await FirebaseAPI.send({
to: recipient,
title: template.subject,
body: template.body
});
}
}
The Solution: Regular architectural reviews where you zoom out and look for patterns:
- Weekly or sprint-based refactoring sessions
- Code reviews that focus on architecture, not just correctness
- Periodic “architectural health checks” where teams assess system-wide design
Technical Debt in Tests
As the architecture evolves, tests may accumulate technical debt (Feathers, 2004). It’s important to refactor tests along with production code to ensure they continue providing clear feedback about architectural quality.
Common forms of test technical debt:
- Outdated test structure that doesn’t match current architecture
- Duplicated test setup across multiple test files
- Fragile tests that break due to minor architectural changes
- Unclear tests that don’t communicate architectural intent
Example: Test debt accumulation
interface TestDatabase {
users: User[];
}
interface TestCache {
get(key: string): any;
set(key: string, value: any): void;
}
interface TestEmailer {
send(to: string, subject: string, body: string): void;
}
interface TestAnalytics {
track(event: string, data: any): void;
}
// Tests written when architecture was simpler
// Note: TestDatabase, TestCache, TestEmailer, TestAnalytics are simple
// in-memory test doubles (full implementation not shown for brevity)
describe('UserService (old tests)', () => {
it('should create user', async () => {
const db = {} as TestDatabase;
const cache = {} as TestCache;
const emailer = {} as TestEmailer;
const analytics = {} as TestAnalytics;
const service = new UserService(db, cache, emailer, analytics);
await service.createUser({ name: 'John', email: 'john@example.com' });
expect(db.users).toHaveLength(1);
});
it('should validate email', async () => {
const db = {} as TestDatabase;
const cache = {} as TestCache;
const emailer = {} as TestEmailer;
const analytics = {} as TestAnalytics;
const service = new UserService(db, cache, emailer, analytics);
await expect(
service.createUser({ name: 'John', email: 'invalid' })
).rejects.toThrow('Invalid email');
});
// 20 more tests with duplicated setup...
});
As the architecture evolves, this test debt makes changes painful. Refactor tests to match current architectural understanding:
interface TestContext {
repositories: {
users: InMemoryUserRepository;
};
services: {
emailer: MockEmailService;
analytics: MockAnalyticsService;
};
buildUserService(): UserService;
}
// Refactored tests matching evolved architecture
describe('UserService', () => {
let testContext: TestContext;
beforeEach(() => {
testContext = createTestContext(); // Builder pattern for test setup
});
it('should create user with valid data', async () => {
const service = testContext.buildUserService();
const result = await service.createUser({
name: 'John',
email: 'john@example.com'
});
expect(result.isSuccess).toBe(true);
expect(testContext.repositories.users.count()).toBe(1);
});
it('should reject invalid email', async () => {
const service = testContext.buildUserService();
const result = await service.createUser({
name: 'John',
email: 'invalid'
});
expect(result.isFailure).toBe(true);
expect(result.error).toBe('Invalid email format');
});
});
// Test infrastructure evolves with architecture
function createTestContext(): TestContext {
const repositories = {
users: new InMemoryUserRepository()
};
const services = {
emailer: new MockEmailService(),
analytics: new MockAnalyticsService()
};
return {
repositories,
services,
buildUserService() {
return new UserService({
userRepository: repositories.users,
emailService: services.emailer,
analyticsService: services.analytics
});
}
};
}
Best practices for managing test technical debt:
- Refactor tests during the refactor phase of TDD
- Extract test builders and factories for complex setup
- Review test code with the same rigor as production code
- Delete obsolete tests that no longer provide value
- Keep test structure aligned with production architecture
Conclusion
Test-Driven Development is not merely a technique for ensuring code correctness—it’s a powerful mechanism for discovering and validating architectural decisions (Beck, 2002; Freeman & Pryce, 2009). By creating short feedback loops through the Red-Green-Refactor cycle, TDD enables developers to make architectural choices based on empirical evidence rather than pure speculation.
The relationship between TDD and architecture is bidirectional:
Tests → Architecture: Tests drive architectural improvements by making pain points visible. When tests are difficult to write, they signal design problems—tight coupling, unclear responsibilities, missing abstractions. This feedback mechanism continuously pushes the architecture toward better structure.
Architecture → Tests: Good architecture makes tests easier to write. Well-separated concerns, clear interfaces, and proper dependency management all emerge from the constraints of testability, and they make subsequent testing simpler.
This feedback loop creates a virtuous cycle where tests improve architecture, which improves tests, which improves architecture.
Key Takeaways
The practice of test-driving architecture encourages:
Early validation of architectural decisions: Each test provides immediate feedback about design choices, allowing course correction before significant investment. Research demonstrates that catching architectural problems early reduces the cost of fixing them by orders of magnitude (Bhat & Nagappan, 2006).
Continuous refinement of system structure: TDD’s refactoring phase provides regular opportunities to improve architecture based on accumulated knowledge. Unlike big-bang architectural refactorings, continuous refinement is lower risk and better informed by actual requirements.
Testability as a first-class architectural quality: Designing for testability inherently produces architectures with low coupling, high cohesion, clear interfaces, and explicit dependencies—all hallmarks of well-designed systems. Janzen and Saiedian’s research confirms that TDD practitioners produce code with superior internal quality metrics (Janzen & Saiedian, 2008).
Emergent design that adapts to actual needs: Rather than over-engineering for hypothetical future requirements, test-driven architecture grows incrementally based on concrete use cases. This approach aligns with the Agile principle of responding to change over following a plan (Beck & others, 2001).
The Path Forward
When we embrace TDD not just as a testing practice but as an architectural tool, we create systems that are not only correct and maintainable at the code level, but also well-structured and adaptable at the architectural level. The short feedback loops inherent in TDD become a guiding force, helping us build architectures that serve both current needs and future evolution.
For practitioners looking to apply these principles:
- Start small: Begin with a single use case and let the architecture emerge from the patterns needed to support it.
- Listen to test pain: Treat difficult tests as design feedback, not just implementation problems.
- Refactor continuously: Use the refactor phase to extract architectural patterns as they become apparent.
- Balance emergence with vision: Maintain lightweight architectural guidance while allowing tests to validate specific implementation decisions.
- Keep tests clean: Refactor test code with the same discipline as production code to prevent technical debt.
The evidence from both research and professional practice suggests that test-driven architecture—where short feedback loops guide architectural decisions—produces systems that are more maintainable, more flexible, and better aligned with actual requirements than those designed purely through upfront planning (Erdogmus et al., 2005; Rafique & Mišić, 2013).
In the end, test-driving architecture is about making architectural decisions deliberately, validating them empirically, and refining them continuously. It’s about building systems that grow organically from their requirements while maintaining structural integrity. And most importantly, it’s about receiving rapid, honest feedback that keeps our architectural decisions grounded in reality rather than speculation.
References
Academic Research
This post draws extensively on empirical research about Test-Driven Development and its impact on software design and architecture:
(Janzen & Saiedian, 2005) - Foundational research establishing TDD concepts and taxonomy
(George & Williams, 2003) - Early industrial investigation of TDD practices
(Erdogmus et al., 2005) - Empirical study on the effectiveness of test-first programming
(Bhat & Nagappan, 2006) - Industrial case studies evaluating TDD efficacy
(Janzen & Saiedian, 2008) - Research investigating TDD’s impact on software design quality
(Causevic et al., 2011) - Systematic review of factors limiting TDD adoption
(Rafique & Mišić, 2013) - Meta-analysis of TDD’s effects on external quality and productivity
Books and Professional Literature
(Beck, 2002) - Kent Beck’s seminal work introducing Test-Driven Development
(Freeman & Pryce, 2009) - Steve Freeman and Nat Pryce’s comprehensive guide to outside-in TDD and emergent architecture
(Martin, 2017) - Robert C. Martin’s principles of clean architecture and design
(Martin, 2003) - SOLID principles and their application in agile development
(Feathers, 2004) - Michael Feathers’ strategies for working with legacy code and improving testability
Additional Resources
(Beck & others, 2001) - The Agile Manifesto establishing principles of emergent design
(Mugridge, 2003) - Connecting TDD practices to scientific method
(Fowler, 2005) - Martin Fowler’s writings on TDD principles and practices
Related reading
- I Am Doubling Down on TDD in the Age of AI, on why the loop is worth keeping when an agent writes the code and the design has to stay honest.
- Test-Driven Development with Agents for TypeScript, a worked setup for driving an agent through the cycle.
- TDD anti-patterns, for the test smells that signal the architecture is going in the wrong direction.
- Clean architecture and architectural decision records, for recording the structure once the tests have revealed it.