Case Studies

Building BuildEstate Pro: How an Enterprise Property Platform Was Designed and Delivered

Afzal AhmedAfzal Ahmed
·15 June 2026·42 min read
.NET 8ASP.NET CoreClean ArchitectureCQRSMediatREF CoreSQL ServerAngular 20NgRx

Why This Matters

A source-backed, beginner-friendly engineering case study showing how BuildEstate Pro moved from business vision and domain discovery to Clean Architecture, CQRS, state machines, ASP.NET Core, EF Core, Angular, NgRx, security, search, testing and a repeatable Definition of Done.

Building BuildEstate Pro: How an Enterprise Property Platform Was Designed, Architected and Delivered

BuildEstate Pro began with a deliberately difficult question: how would you design real enterprise software for a property-development company when the work crosses land acquisition, planning, legal compliance, construction, finance, sales and reporting?

That question matters because business software is rarely a collection of unrelated screens. A development opportunity becomes a negotiated offer. An accepted offer creates legal work. Planning conditions affect construction. Costs alter feasibility. Documents, approvals, dates and decisions must remain traceable. Different people need different views and permissions, but they are all working with the same business story.

This article is a guided engineering case study. It explains not only what exists in BuildEstate Pro, but how a substantial application moves from an idea to a maintainable codebase. We will walk gently through the decisions: understanding the problem, defining the domain, choosing an architecture, implementing one feature vertically, protecting workflow rules, building a reusable interface, testing behaviour and documenting the system so another developer can continue the work.

You do not need to know Clean Architecture, CQRS, state machines or NgRx before reading. I will introduce each idea before showing where it belongs. The aim is not to impress you with pattern names. The aim is to help you recognise the problems those patterns solve—and when a simpler approach would be enough.

Repository snapshot: this case study is based on the public BuildEstate Pro source repository. At the time of review, Land Acquisition, Planning & Approvals, and Legal & Compliance were the completed business modules. Security, User Management, Notifications and Global Search formed the shared platform foundation. Construction, Finance, Sales and the other named areas remain roadmap modules. Honest software documentation separates what is working from what is intended.

1. Begin with the business problem, not the framework

It is tempting to start a new project by creating an ASP.NET Core API and an Angular application. That feels productive because files appear immediately. Yet the first risk in enterprise development is rarely syntax. It is building the wrong model of the business.

Property-development teams often coordinate work through spreadsheets, email threads, shared drives and specialist systems. Each tool can be useful, but the business process becomes fragmented:

  • acquisition teams assess land and negotiate with owners;
  • planning teams submit applications, record decisions and discharge conditions;
  • legal teams manage contracts, title issues, obligations and compliance;
  • project teams need dates, responsibilities, dependencies and risks;
  • finance teams need reliable costs, commitments, forecasts and returns;
  • directors need a portfolio view without asking five teams for five reports.
The engineering problem is therefore not “make a property form.” It is “create a dependable system of record for connected work, while preserving ownership, security and auditability.” That sentence changes every later decision.

The repository captures this thinking in its project vision. A vision document should answer four beginner-friendly questions:

  1. Who uses the product? Roles such as acquisition managers, planners, legal staff, finance users and administrators.
  2. What outcome do they need? A controlled journey from opportunity through delivery, rather than disconnected records.
  3. What qualities must the system have? Security, performance, availability, accessibility, traceability and maintainability.
  4. How will we know it works? Measurable targets and completed business workflows.
The project records non-functional goals including a p95 API response target below 200 milliseconds, support for hundreds of concurrent users, 99.9% availability, recovery objectives and WCAG 2.1 AA accessibility. A non-functional requirement describes how well a system should work rather than what business action it performs. “Create an opportunity” is functional. “Return normal API requests quickly” is non-functional.

These targets do not magically become true because they are written down. They guide design and testing. A performance target encourages pagination and database indexes. An availability target encourages health checks, observability and recoverable deployment. An accessibility target affects every component, not a final polish phase.

Mentoring lesson: write down the pressure before choosing the pattern

Architecture is a response to pressure. If five people use a short-lived internal tool, a simple CRUD application may be ideal. If many roles operate long-running, legally significant workflows, you need stronger boundaries, permissions and history. BuildEstate Pro uses several enterprise patterns because the domain creates those pressures—not because every application needs them.

2. Turn a broad vision into a map

“Property development” is too large to implement as one feature. The next step is decomposition: divide the problem into business capabilities that people can discuss, build and test.

BuildEstate Pro identifies fourteen business modules, including Land Acquisition, Planning & Approvals, Legal & Compliance, Project Management, Construction, Procurement, Contractors, Finance, Investors, Property Units, Sales, Rental, Documents and Reports. It also identifies shared capabilities such as authentication, authorization, users, notifications and search.

A module is a coherent area of business responsibility. Land Acquisition owns opportunities, offers, due diligence and acquisition decisions. Planning owns applications, consultations, conditions and appeals. A good boundary makes a sentence like “Planning owns this rule” feel natural.

This does not require fourteen separately deployed services. BuildEstate Pro is organised as a modular application: one solution and deployment can contain clear feature boundaries. This is often called a modular monolith. “Monolith” only means the application is deployed as a unit; it does not mean the internals must be tangled.

For many teams, this is a pragmatic starting point:

  • one transaction can update related data safely;
  • local development is straightforward;
  • deployment and monitoring are simpler than a distributed system;
  • modules can still have explicit ownership and dependencies;
  • service extraction remains possible if scale or team autonomy later justifies it.
Microservices add network failures, message delivery, distributed tracing, eventual consistency and operational cost. Those costs can be worthwhile, but they should purchase a real benefit. Starting with well-separated modules lets the design earn complexity gradually.

Build a delivery map

A roadmap is more trustworthy when it distinguishes states. One useful set of labels is:

  • planned — understood at a high level but not implemented;
  • designed — requirements and technical approach documented;
  • in progress — active code exists but the Definition of Done is unmet;
  • complete — the agreed backend, frontend, tests and documentation are present;
  • operational — deployed, monitored and proven with real usage.
The public repository describes three completed business modules rather than pretending the entire fourteen-module vision is finished. This is a valuable engineering habit. Roadmaps express direction; release notes express evidence.

3. Learn the language of the domain

Before designing tables, learn the words the business uses. In Domain-Driven Design this shared vocabulary is called a ubiquitous language. The phrase sounds academic, but the idea is simple: business people, documentation, code and tests should mean the same thing when they say “opportunity,” “offer,” “condition” or “contract.”

Suppose one screen calls a record a “site,” the API calls it a “lead,” and the database calls it an “asset.” Developers will spend time translating, and subtle differences will hide. If the business distinguishes a land opportunity from an acquired site, the software should preserve that distinction.

BuildEstate Pro’s land-acquisition lifecycle can be summarised as:

Identify → Evaluate → Offer → Contract → Registry → Acquired

That line already exposes useful questions:

  • What information is required before evaluation?
  • Can there be several offers?
  • Who may approve an offer?
  • What due-diligence failures block a contract?
  • Can a rejected opportunity be reopened?
  • What evidence is required before marking land as acquired?
These are domain questions, not controller questions. A workshop with stakeholders would explore the happy path and exceptions, record terms precisely, and identify the decisions that must remain auditable.

Use scenarios before entities

Beginners are often taught to list nouns and turn them into database tables. Nouns matter, but scenarios reveal behaviour. Write small stories:

An acquisition manager creates an opportunity with a site name, location, asking price and owner information. The system validates mandatory data, assigns an identifier, records who created it and makes it visible in the acquisition pipeline.
An authorised approver accepts an offer. The offer may move only from a permitted current state. The system records the decision and can notify relevant users.
From these stories we discover commands, rules, permissions, events and queries. The data model follows the behaviour rather than leading it.

4. Choose architecture that keeps business rules visible

The solution uses Clean Architecture. At its heart is one dependency rule: source-code dependencies point toward the business core.

The layers in this repository are:

BuildEstate.API             HTTP endpoints and application startup
        ↓
BuildEstate.Application     use cases, commands, queries, validation
        ↓
BuildEstate.Domain          entities, value objects, rules and events

BuildEstate.Infrastructure  EF Core, Identity, storage and integrations
        ↓
BuildEstate.Domain

The Domain project has no reason to reference ASP.NET Core, SQL Server or Angular. An Opportunity should know whether a transition is valid, but it should not know how an HTTP request arrived or how a row is stored.

Why is this helpful?

  • business rules can be tested without starting a web server;
  • database details do not leak into every use case;
  • controllers stay small;
  • changing an integration affects an adapter rather than the core model;
  • a developer can find code by responsibility.
The repository’s architecture guide makes these boundaries explicit. That document is as important as the folder structure because a folder cannot explain why a dependency is forbidden.

What each layer is allowed to know

The Domain holds enduring business concepts: entities, status transitions, domain events and value objects. It should be meaningful even if the user interface and database technology change.

The Application describes use cases. “Create opportunity,” “approve offer” and “search opportunities” belong here. It coordinates domain behaviour and persistence abstractions.

The Infrastructure supplies technical implementations: Entity Framework Core mappings, SQL Server access, ASP.NET Core Identity, token services, file storage and background mechanisms.

The API translates HTTP into application requests and application results into HTTP responses. It also hosts middleware and dependency registration.

This separation is not about creating the maximum number of projects. It is about controlling reasons for change. If a class changes whenever a business rule, SQL mapping and HTTP contract change, it owns too many responsibilities.

5. CQRS: separate intentions from questions

BuildEstate Pro uses CQRS with MediatR. CQRS means Command Query Responsibility Segregation. The practical rule is:

  • a command asks the system to change something;
  • a query asks the system to return information without changing business state.
Examples:
public sealed record CreateOpportunityCommand(
    string Name,
    string Address,
    decimal AskingPrice) : IRequest<Guid>;

public sealed record GetOpportunityByIdQuery(Guid Id)
    : IRequest<OpportunityDetailsDto?>;

The command is named as an instruction. The query is named as a question. MediatR sends each request to one handler:

public sealed class CreateOpportunityHandler
    : IRequestHandler<CreateOpportunityCommand, Guid>
{
    public async Task<Guid> Handle(
        CreateOpportunityCommand request,
        CancellationToken cancellationToken)
    {
        // Validate the use case, create the domain entity,
        // add it to persistence, save, and return its ID.
    }
}

Why not place all this code directly in a controller? Because HTTP is only the delivery mechanism. A use case deserves a name, validation, tests and a focused implementation. Thin controllers make the application easier to call from a background job or another interface later.

Commands and queries can use persistence differently

Commands often load tracked entities because they will change them. Queries can project directly into a response shape and use AsNoTracking() because no update is required:

var result = await db.Opportunities
    .AsNoTracking()
    .Where(x => x.Id == request.Id)
    .Select(x => new OpportunityDetailsDto(
        x.Id,
        x.Name,
        x.Status,
        x.AskingPrice))
    .SingleOrDefaultAsync(cancellationToken);

Projection means SQL returns only the fields needed by the screen. This can reduce memory, data transfer and accidental loading of large object graphs.

CQRS does not automatically mean two databases, event sourcing or distributed services. In this solution, commands and queries can share EF Core and SQL Server while keeping their responsibilities clear. Start with the smallest useful interpretation of a pattern.

6. Build cross-cutting foundations early

A business module is not complete if it works only for an anonymous developer on a laptop. Real features need identity, authorization, auditing, errors, observability and often notifications. BuildEstate Pro establishes these as platform capabilities.

Authentication and authorization are different

Authentication answers “Who are you?” A user signs in, and the backend validates identity. Authorization answers “What are you allowed to do?” An authenticated viewer may read an opportunity but lack permission to approve an offer.

The repository documents thirteen roles and forty-three permissions. Role-based access control makes administration manageable, while permission checks let code express capabilities precisely. A policy might require LandAcquisition.Opportunity.Create rather than merely checking whether a user is an “Admin.”

This matters for both security and maintainability. Role names evolve with organisations. Business permissions are often more stable.

Never rely only on hiding an Angular button. The interface should hide actions users cannot perform because that is a better experience, but the API must enforce the rule because browser requests can be constructed manually.

Audit changes centrally

Enterprise users need answers to “who changed this and when?” If every handler manually sets audit fields, somebody will eventually forget. The solution uses an EF Core save interceptor to apply audit behaviour around persistence.

A simplified idea looks like this:

public override InterceptionResult<int> SavingChanges(
    DbContextEventData eventData,
    InterceptionResult<int> result)
{
    foreach (var entry in eventData.Context!.ChangeTracker.Entries<AuditableEntity>())
    {
        if (entry.State == EntityState.Added)
        {
            entry.Entity.CreatedAtUtc = clock.UtcNow;
            entry.Entity.CreatedBy = currentUser.Id;
        }

        if (entry.State == EntityState.Modified)
        {
            entry.Entity.UpdatedAtUtc = clock.UtcNow;
            entry.Entity.UpdatedBy = currentUser.Id;
        }
    }

    return result;
}

Centralisation creates a default that developers receive automatically. Sensitive actions may still need richer business audit records—what decision was made, old and new values, reason and correlation identifier—but basic metadata should not depend on memory.

Use one error contract

Validation errors, missing records, forbidden actions and unexpected failures should not each invent a response shape. Exception middleware can translate known application exceptions into consistent HTTP responses. The Angular client then has one predictable contract to display or log.

Similarly, correlation-ID middleware gives every request an identifier that can follow it through logs and background work. When a user reports a problem, “it failed around 10:03” is far less useful than a searchable correlation ID.

7. A repeatable workflow for every module

One of the strongest ideas in the repository is not a class. It is a documented method for adding the next module. The module-building guide turns architecture into a repeatable delivery sequence.

Here is that sequence in teaching form.

Step 1: define the outcome and boundary

Write what the module owns, who uses it and what is deliberately outside scope. List its states, important decisions, integrations and permissions. Agree vocabulary.

A weak requirement says “build offers.” A useful requirement says who creates an offer, which opportunity it belongs to, its mandatory commercial terms, who approves it, allowed status changes, and what other work begins after acceptance.

Step 2: model the lifecycle

Draw the normal path and the exceptions. Identify transitions, guards and terminal states. A guard is a condition that must be true before movement is permitted.

Draft → Submitted → Under Review → Approved
  │          │             │
  └→ Cancelled       Rejected/Returned

Ask what happens when data is stale, an approval is withdrawn, two users edit concurrently or a dependent record is missing.

Step 3: design data deliberately

Define entities, relationships, value constraints, indexes, ownership and delete behaviour. Add audit fields and an optimistic concurrency token where conflicting updates matter.

Do not make every string nvarchar(max). A code, title and description have different limits. Do not index every column either. Index fields that support actual filtering, joining, uniqueness and ordering.

Step 4: define the API contract

List commands, queries, request shapes, response DTOs and failure cases. Decide pagination and filtering before returning an unbounded table. Keep the public contract separate from EF entities; the database model should not accidentally become the API.

Step 5: implement backend vertical slices

Create one end-to-end use case at a time: request, validator, handler, mapping, endpoint and tests. A vertical slice crosses the necessary layers for one user outcome. It is easier to prove than building every entity first, every repository next and every screen last.

Step 6: implement frontend state and screens

Define the page state, user actions, API service, NgRx effect, reducer, selectors and components. Cover loading, empty, error and permission states—not only the successful screenshot.

Step 7: integrate platform services

Add permissions, audit details, notifications, search indexing/provider support and documents where the use case requires them. These are acceptance criteria, not cleanup tasks.

Step 8: test the behaviour

Test validation, handler outcomes, state transitions, authorization boundaries, persistence and the visible workflow. Prefer tests that express business rules.

Step 9: document and review

Update module guides, API notes, diagrams, component catalogues and operational instructions. Run the Definition of Done. A feature another developer cannot understand or operate is unfinished.

This workflow reduces cognitive load. Developers do not need to invent a new project structure for every module; they focus on the business differences.

8. Trace one feature from browser to database

Let us follow “create land opportunity” through the stack. This is where architecture stops being a diagram.

Angular form
  → dispatch NgRx action
  → effect calls Angular service
  → HTTP POST reaches API controller
  → controller sends MediatR command
  → validation pipeline checks input
  → handler creates domain entity
  → EF Core writes SQL Server row
  → response returns ID
  → effect dispatches success action
  → reducer updates store
  → selector refreshes the view

The form expresses user intent

The Angular component gathers data and performs immediate, friendly validation. Client validation improves usability, but it is not trusted as security. The component dispatches an action rather than embedding networking and state changes in the template:

save(): void {
  if (this.form.invalid) {
    this.form.markAllAsTouched();
    return;
  }

  this.store.dispatch(OpportunityActions.createRequested({
    opportunity: this.form.getRawValue()
  }));
}

The action is a fact about intent: creation was requested. An NgRx effect handles asynchronous work:

createOpportunity$ = createEffect(() =>
  this.actions$.pipe(
    ofType(OpportunityActions.createRequested),
    exhaustMap(({ opportunity }) =>
      this.api.create(opportunity).pipe(
        map(created => OpportunityActions.createSucceeded({ created })),
        catchError(error => of(
          OpportunityActions.createFailed({ error })
        ))
      )
    )
  )
);

exhaustMap is useful when repeated submit clicks should be ignored while a request is active. Operator choice is behaviour: switchMap cancels the previous subscription, concatMap queues requests, and mergeMap allows concurrency.

The API translates transport to application intent

A controller endpoint should remain unsurprising:

[HttpPost]
[Authorize(Policy = Permissions.Opportunities.Create)]
public async Task<ActionResult<Guid>> Create(
    CreateOpportunityCommand command,
    CancellationToken cancellationToken)
{
    var id = await mediator.Send(command, cancellationToken);
    return CreatedAtAction(nameof(GetById), new { id }, id);
}

The endpoint enforces permission, passes cancellation and returns 201 Created. It does not implement commercial rules.

Validation runs before the handler

FluentValidation can describe input constraints:

public sealed class CreateOpportunityValidator
    : AbstractValidator<CreateOpportunityCommand>
{
    public CreateOpportunityValidator()
    {
        RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
        RuleFor(x => x.Address).NotEmpty().MaximumLength(500);
        RuleFor(x => x.AskingPrice).GreaterThanOrEqualTo(0);
    }
}

A MediatR pipeline behaviour executes validators consistently. The handler can assume basic request validity and focus on the use case. Database constraints should still protect invariants at the final boundary.

The handler coordinates, the domain decides

The handler obtains the current user, creates the entity through a constructor or factory, persists it, publishes domain effects as designed, and returns the identifier. If a rule belongs to the opportunity itself, put it on the domain model rather than repeating it across handlers.

var opportunity = Opportunity.Create(
    request.Name,
    request.Address,
    Money.Gbp(request.AskingPrice),
    currentUser.UserId);

db.Opportunities.Add(opportunity);
await db.SaveChangesAsync(cancellationToken);
return opportunity.Id;

Notice how the code tells a business story. This readability is more valuable than clever abstraction.

The reducer records a new immutable state

When the effect succeeds, a reducer returns a new NgRx state:

on(OpportunityActions.createSucceeded, (state, { created }) => ({
  ...state,
  items: [created, ...state.items],
  saving: false,
  error: null
}))

A selector exposes only what a component needs. This flow creates predictable transitions and supports Redux developer tooling. For a tiny local form, component state would be simpler. NgRx earns its place when state is shared, asynchronous and important enough to trace.

9. State machines protect workflow integrity

A status field looks harmless:

opportunity.Status = OpportunityStatus.Acquired;

But that assignment permits Draft to jump directly to Acquired, allows terminal records to reopen silently and spreads transition rules across controllers and handlers.

A finite state machine defines:

  • a finite set of states;
  • events or commands that trigger movement;
  • permitted transitions;
  • optional guards and side effects.
BuildEstate Pro uses state-machine classes for workflow-driven entities including opportunities, offers, due diligence, contracts, planning applications, conditions, appeals and legal records.

A simplified transition method is:

public void MoveTo(OpportunityStatus next)
{
    if (!OpportunityStateMachine.CanTransition(Status, next))
        throw new InvalidOperationException(
            $"Cannot move opportunity from {Status} to {next}.");

    Status = next;
    AddDomainEvent(new OpportunityStatusChanged(Id, next));
}

The policy can be represented as data:

private static readonly IReadOnlyDictionary<OpportunityStatus,
    OpportunityStatus[]> Allowed = new Dictionary<OpportunityStatus,
    OpportunityStatus[]>
{
    [OpportunityStatus.Identified] =
        [OpportunityStatus.Evaluating, OpportunityStatus.Rejected],
    [OpportunityStatus.Evaluating] =
        [OpportunityStatus.OfferStage, OpportunityStatus.Rejected],
    [OpportunityStatus.OfferStage] =
        [OpportunityStatus.ContractStage, OpportunityStatus.Rejected],
    [OpportunityStatus.ContractStage] =
        [OpportunityStatus.Registry, OpportunityStatus.Withdrawn],
    [OpportunityStatus.Registry] =
        [OpportunityStatus.Acquired]
};

The exact repository model contains its own statuses and rules; this shortened example teaches the shape. Centralising transitions gives the UI, API and tests one policy to reason about.

Test rules, not just examples

An example test proves one path:

[Fact]
public void Identified_can_move_to_evaluating()
{
    OpportunityStateMachine.CanTransition(
        OpportunityStatus.Identified,
        OpportunityStatus.Evaluating).Should().BeTrue();
}

Property-based testing checks a rule over many generated inputs. Useful properties include:

  • an undefined transition is always rejected;
  • terminal states have no outgoing transitions;
  • moving to the current state is either consistently allowed or rejected;
  • every declared target is a valid enum value;
  • rejected transitions leave the entity unchanged.
State machines suit property tests because a small set of invariants covers a large input space. The repository includes property-oriented tests across workflows, authorization and security behaviour.

10. Persistence: model data for correctness and reading

The backend uses Entity Framework Core 8 with SQL Server. EF Core maps objects to relational tables and translates LINQ queries to SQL. It saves repetitive data-access code, but it does not remove the need to understand databases.

Keep EF configuration explicit

Entity configurations can define lengths, precision, indexes, relationships and delete rules:

builder.Property(x => x.Name)
    .HasMaxLength(200)
    .IsRequired();

builder.Property(x => x.AskingPrice)
    .HasPrecision(18, 2);

builder.HasIndex(x => new { x.Status, x.CreatedAtUtc });

builder.Property(x => x.RowVersion)
    .IsRowVersion();

Money needs explicit decimal precision. Common pipeline filters may justify a composite index on status and date. RowVersion supports optimistic concurrency: when two users update the same record, the second save can detect that the row changed rather than silently overwriting newer work.

Understand tracking

EF Core tracks loaded entities so it can detect modifications. That is useful for commands. For read-only lists, tracking consumes memory without benefit, so queries use AsNoTracking() and projection.

Avoid accidentally loading every child collection. A dashboard rarely needs full documents, offers, notes and audit history for every opportunity. Ask the database for the summary the screen requires.

Pagination is a correctness feature

An endpoint that returns all records works in a demo and fails as data grows. A paged query has a page number, page size, total count and stable ordering. Cap the permitted page size on the server; never let a caller request a million rows.

For deep datasets, keyset pagination may outperform large Skip values. The right choice depends on navigation needs. The lesson is to make growth part of API design before production data forces an emergency rewrite.

Soft deletion needs a complete policy

Soft deletion records that an item is deleted instead of removing its row. It can support recovery and audit, but it adds complexity: every query must exclude deleted rows, unique constraints need thought, and related data needs consistent behaviour. A global query filter can provide a safe default, with explicit administrative access when required.

11. Angular and NgRx: make UI state observable

The client uses Angular 20, standalone components, TypeScript, RxJS and NgRx. Tailwind CSS and DaisyUI provide styling foundations.

A modern Angular application usually has three broad areas:

  • core — singleton services, authentication, guards, interceptors and global configuration;
  • shared — reusable presentational components, pipes and utilities;
  • features — business screens and state organised by module.
Standalone components declare their own imports rather than depending on large feature modules. ChangeDetectionStrategy.OnPush helps Angular avoid unnecessary checking when inputs and observable values have not changed.

Separate server state, feature state and local state

Not all state belongs in NgRx. A modal’s open/closed flag may remain local. A list shared across several routes, with loading, errors, filters and mutations, may justify a store.

An NgRx feature commonly contains:

opportunity.actions.ts   events and user intentions
opportunity.effects.ts   asynchronous API work
opportunity.reducer.ts   pure state transitions
opportunity.selectors.ts derived readable state

A reducer must be pure: the same state and action produce the same next state, without calling an API or mutating the existing object. Effects handle impure work. Selectors calculate reusable views such as open opportunities or whether the current page is loading.

This separation makes debugging chronological. “Requested,” “succeeded” and “failed” actions show what happened. It also makes error and retry paths first-class rather than scattered subscribe() callbacks.

Smart and presentational components

A container component can select data and dispatch actions. A presentational component receives inputs and emits user events:

@Input({ required: true }) opportunities: OpportunitySummary[] = [];
@Output() selected = new EventEmitter<string>();

This component does not know NgRx or HTTP. It is easier to test, reuse and show in a component catalogue.

Accessibility is behaviour

An accessible interface needs semantic elements, labels, keyboard operation, visible focus, sufficient contrast, meaningful error messages and correct ARIA only where native HTML is insufficient. A custom dialog also needs focus trapping, escape behaviour, focus restoration and screen-reader labelling.

These details belong in reusable components so every feature benefits. Accessibility is not a colour-checking exercise at the end.

12. A design system is a governed product

BuildEstate Pro’s repository documents a sizeable reusable component system, with forty-nine components and themes. The more important lesson is the governance process around it.

Without governance, every feature team creates a slightly different table, badge, modal and form field. The application becomes inconsistent and fixes must be repeated. A design system creates shared visual language and shared behaviour.

The frontend governance guide follows a “search before create” principle. If an existing component covers most of a need, extend it rather than duplicating it. Reusable controls should support clear inputs and outputs, accessibility, validation integration and documented usage.

A practical contribution flow is:

  1. search the component catalogue;
  2. evaluate whether composition or an extra input can meet the need;
  3. confirm the requirement is general, not a one-screen exception;
  4. build the component with a narrow API;
  5. add tests, examples and accessibility behaviour;
  6. export it through the public barrel;
  7. document when to use and not use it.
This process prevents the shared library becoming a dumping ground. Reuse is beneficial when it captures a stable concept. A “universal component” with thirty flags is often harder to maintain than two focused components.

The repository’s design-system executive summary and governance material show how interface consistency becomes an engineering concern rather than an informal preference.

13. Global Search as an extensibility case study

Global Search is an excellent example of designing for additions without continually modifying a central class. The application has multiple business modules, each of which knows how its own records should be searched and permission-filtered.

An interface defines the extension point:

public interface ISearchProvider
{
    string Category { get; }

    Task<IReadOnlyCollection<SearchResult>> SearchAsync(
        SearchRequest request,
        SearchContext context,
        CancellationToken cancellationToken);
}

Each module implements a provider. The aggregator receives all registered providers through dependency injection. Adding another searchable module means implementing and registering a provider; the aggregator, controller and front end do not need a new switch statement.

This demonstrates the Open/Closed Principle: a component is open for extension but closed to repeated modification. The principle is valuable when additions are genuinely expected. Do not create plug-in machinery for a system with one fixed search source.

Run independent work concurrently

The repository’s search documentation describes fourteen providers executed with Task.WhenAll. If providers are independent, concurrency reduces total latency from roughly the sum of every duration toward the duration of the slowest provider.

var tasks = providers.Select(provider =>
    SearchProviderSafely(provider, request, context, token));

var groups = await Task.WhenAll(tasks);
return RankAndLimit(groups.SelectMany(x => x));

The production-minded details matter:

  • a linked cancellation token applies a provider timeout;
  • one failed provider can yield partial results instead of destroying all results;
  • read queries use AsNoTracking();
  • result counts are capped;
  • a short cache reduces repeated work;
  • rate limiting protects the endpoint;
  • indexes and, where suitable, full-text search support database work.
The documented implementation uses a five-second provider timeout, a thirty-second memory cache and a ten-requests-per-second limit. These numbers are policies, not universal truths. Measure real traffic and tune them.

Search must preserve authorization

Global search can accidentally become a data-leak endpoint. It must apply the current user’s permissions before returning titles, snippets or identifiers. Filtering only after loading forbidden data may still expose information through logs, timing or counts.

The global-search technical guide also covers validation, output encoding, configuration and testing. Cross-module features are valuable architecture tests because they reveal whether boundaries are explicit.

14. Domain events and notifications reduce direct coupling

When an offer is accepted, several reactions may follow: create an audit entry, notify legal staff, refresh a dashboard or begin contract work. The offer handler could call every subsystem directly, but then it becomes coupled to all future reactions.

A domain event records that something meaningful happened:

public sealed record OfferAcceptedDomainEvent(
    Guid OfferId,
    Guid OpportunityId,
    DateTime AcceptedAtUtc) : IDomainEvent;

Handlers can react independently:

public sealed class NotifyLegalTeam
    : INotificationHandler<OfferAcceptedDomainEvent>
{
    public Task Handle(
        OfferAcceptedDomainEvent notification,
        CancellationToken cancellationToken)
        => notificationService.NotifyLegalAsync(
            notification.OpportunityId,
            cancellationToken);
}

Events reduce direct references, but they introduce questions. Is delivery synchronous? What happens if notification fails after the database commits? Can it be retried? Is a handler idempotent—safe to run twice?

For critical external side effects, an outbox pattern is often appropriate: save the business change and an event record in one database transaction, then let a background worker publish pending events reliably. This repository demonstrates notification and domain-event foundations; production deployment should validate failure and retry semantics for every important path.

The broader lesson is that loose coupling does not eliminate responsibility. It moves responsibility into delivery guarantees, monitoring and idempotency.

15. Security is a system, not a login screen

The security foundation includes ASP.NET Core Identity, JWT bearer authentication, roles, permissions, token/session concerns and auditability. A robust design considers the whole lifecycle:

  1. register or provision a user safely;
  2. store passwords using a proven identity library, never custom encryption;
  3. issue short-lived access tokens with appropriate claims;
  4. validate issuer, audience, signature and lifetime;
  5. refresh or revoke sessions according to policy;
  6. enforce permissions at API boundaries and sensitive use cases;
  7. audit administrative and high-impact actions;
  8. avoid placing secrets or sensitive personal data in logs.
JWTs are signed tokens, not automatically encrypted containers. Anyone holding a normal JWT can often decode its claims, so never place confidential values inside simply because the token is signed.

Authorization tests should cover both sides: permitted users succeed and unpermitted users fail. Property-style tests can verify that no role outside an allowed set receives a sensitive permission, or that revoked sessions cannot produce valid access.

Threat modelling helps the team ask concrete questions: Can a user change an ID in a URL and read another project? Can search reveal a forbidden record? Can a deleted or disabled account keep refreshing? Can a spreadsheet export bypass field restrictions? Security boundaries run through features.

The public repository includes a detailed security feature guide for readers who want to trace the implementation.

16. Testing should follow risk

The repository contains backend and frontend test suites spanning handlers, validators, state machines, security, search and UI state. A useful test strategy is layered.

Unit tests

Unit tests exercise one class or rule quickly. They are ideal for:

  • validation boundaries;
  • allowed and forbidden transitions;
  • calculations;
  • reducer state changes;
  • selectors;
  • ranking rules.
They provide precise failures, but mocks can create false confidence if no test proves that EF mappings, middleware and SQL work together.

Integration tests

Integration tests connect important pieces: API endpoint, authorization, MediatR pipeline, EF Core configuration and a realistic database. They catch problems such as missing registrations, incorrect routes, translation failures and constraints that unit tests cannot see.

The most valuable integration tests follow business paths:

authorised user creates opportunity
→ response is 201
→ row is persisted with audit fields
→ user can retrieve it
→ unauthorised user cannot perform the same command

Frontend tests

Reducer tests prove state transitions. Effect tests prove success and failure actions. Component tests prove rendering, events, validation and accessibility behaviour. A small number of end-to-end journeys can prove that browser and API agree.

Property-based tests

Example-based testing asks, “Does this chosen case work?” Property-based testing asks, “Does this rule hold across many generated cases?” It is especially useful for state machines, authorization matrices, sorting/ranking, token rules and date ranges.

Imagine a function that clamps page size between 1 and 100. Instead of testing only 0, 20 and 101, generate many integers and assert that every result is within the boundary. The test tool searches cases humans may not think to type.

Test behaviour, not implementation trivia

A brittle test checks private method calls. A durable test checks observable outcomes and invariants. Refactoring internal code should not break a test when user-visible behaviour remains correct.

Coverage is a diagnostic, not a quality score. One hundred executed lines can contain weak assertions. Prioritise financial calculations, permissions, transitions, concurrency and failure recovery because their mistakes are expensive.

17. Documentation is part of the architecture

The repository snapshot reviewed for this article contains more than a hundred Markdown documents. Quantity alone is not success; accuracy and discoverability matter. But the range demonstrates a serious principle: documentation preserves decisions that code cannot express.

Useful documentation levels include:

  • vision — why the product exists and what success means;
  • architecture — boundaries, dependency rules and cross-cutting flows;
  • module guide — lifecycle, roles, commands, queries and screens;
  • decision record — why one option was chosen over alternatives;
  • developer guide — how to build, test and extend the system;
  • operations guide — deployment, configuration, monitoring and recovery;
  • component catalogue — reusable UI APIs and examples.
The repository’s Academy includes a canonical module pattern, next-module workflow and Definition of Done. These documents make architecture teachable.

A new developer should be able to answer:

  • Where does a command go?
  • Where is validation executed?
  • How do I add a permission?
  • How does an Angular action reach SQL Server?
  • Which reusable table or modal should I use?
  • What tests and documentation make my feature complete?
If answers live only in the original author’s memory, the system has a bus-factor problem. Documentation turns individual knowledge into team capability.

Keep documentation close to evidence

Docs drift when nobody checks them. Link documentation updates to the Definition of Done, review code examples, and automate facts where possible. A roadmap badge should not claim “complete” merely because a folder exists.

18. Definition of Done: “coded” is not complete

A feature can compile while still being unsafe, inaccessible or impossible to support. A Definition of Done creates a shared completion standard.

For a BuildEstate Pro-style module, the checklist should cover:

Domain and data

  • vocabulary and ownership are documented;
  • lifecycle transitions and guards are enforced centrally;
  • schema, relationships, constraints and indexes are reviewed;
  • audit and concurrency requirements are implemented;
  • migrations are repeatable and tested.

Backend

  • commands and queries have clear contracts;
  • validation runs consistently;
  • permissions are enforced server-side;
  • cancellation tokens flow through async calls;
  • errors use the standard response format;
  • lists are filtered, ordered and paginated;
  • logging contains useful context without sensitive data.

Frontend

  • loading, empty, success and error states exist;
  • forms show understandable validation;
  • permissions affect navigation and actions;
  • components follow the design system;
  • keyboard and screen-reader behaviour is checked;
  • NgRx state and effects handle retries or duplicate actions appropriately.

Quality and operations

  • important rules have unit tests;
  • critical paths have integration tests;
  • build, lint and tests pass;
  • performance-sensitive queries are inspected;
  • telemetry and health behaviour are defined;
  • documentation and release notes are current;
  • rollback or recovery implications are understood.
The checklist is not bureaucracy if each item prevents a known class of failure. Remove ritual items that add no value and add checks after real incidents teach the team something.

19. What the project taught me

Lesson 1: complexity should live near the business rule

Property development is inherently complex. Architecture cannot remove that complexity, but it can stop it leaking everywhere. A state machine makes workflow complexity explicit. A permission catalogue makes access complexity explicit. A value object makes money or date-range rules explicit.

Scattered complexity is dangerous because no developer can see the whole rule. Named, centralised complexity can be reviewed and tested.

Lesson 2: foundations accelerate later modules

Security, errors, audit, notifications, search and shared components take time before a dramatic business screen appears. Yet the fourth module becomes faster because it inherits those capabilities.

The key is to avoid speculative platforms. Build foundations in response to the first real vertical slices, then generalise when a second or third feature proves repetition.

Lesson 3: vertical slices expose integration risk early

Implementing one complete journey reveals mismatched contracts, missing permissions, awkward state and database assumptions. Building all backend layers before touching the UI postpones that feedback.

A walking skeleton—a thin but working journey through browser, API and persistence—is an excellent first milestone.

Lesson 4: state transitions deserve first-class design

Many enterprise bugs are not bad calculations; they are actions performed at the wrong time by the wrong person. Explicit transitions and guards prevent entire categories of invalid state.

Lesson 5: the read model serves the user’s decision

Entities model consistency. Screens model decisions. A dashboard DTO should be shaped around what a user needs to notice, compare or act upon, rather than exposing a serialized entity graph.

Lesson 6: design systems require social rules

A component library without discovery, review and contribution guidance slowly duplicates itself. “Search before create” is a small rule with compounding value.

Lesson 7: asynchronous work needs failure semantics

Domain events and parallel search look elegant on the happy path. Mature design asks what happens when one provider times out, a handler runs twice, or a notification service is unavailable.

Lesson 8: documentation is a delivery multiplier

A reusable module pattern reduces future design debate. A full-stack trace helps a frontend developer understand the backend and vice versa. Documentation is most valuable when it helps somebody perform a task.

Lesson 9: do not confuse a portfolio roadmap with production evidence

The platform is ambitious, but ambition must not blur status. Completed modules demonstrate patterns. Planned modules test whether those patterns will remain useful. Production readiness additionally requires deployed infrastructure, telemetry, security review, backup rehearsal, load testing and operational ownership.

Lesson 10: good architecture makes change cheaper, not free

Clean boundaries, CQRS and providers reduce the blast radius of change. They do not eliminate data migrations, contract evolution, training or regression testing. The goal is controlled change.

20. What I would do next

The next phase should continue vertically rather than creating empty folders for every roadmap module.

One sensible sequence is:

  1. choose the next highest-value workflow with stakeholders;
  2. map its lifecycle and integrations with completed modules;
  3. build one walking skeleton through Angular, API and SQL;
  4. reuse security, audit, notifications, search and design-system components;
  5. add contract and integration tests at module boundaries;
  6. measure query performance with realistic data volumes;
  7. exercise concurrency and retry cases;
  8. deploy to a production-like environment with health checks and telemetry;
  9. rehearse backup, restore and rollback;
  10. gather user feedback before expanding the module.
The documented architecture targets Azure-friendly deployment, but cloud readiness is not the same as a proven production operation. A deployment should explicitly define compute, database, secrets, storage, monitoring, alerting, CI/CD, environment promotion and cost controls.

Before calling the platform production-ready, I would also conduct threat modelling, dependency scanning, accessibility testing with assistive technology, load testing against target percentiles, recovery exercises and data-retention review.

21. A practical blueprint you can reuse

If you are starting your own serious portfolio or business application, use this compact blueprint.

Discovery

  • describe the problem in business language;
  • identify users, outcomes and constraints;
  • agree vocabulary;
  • map workflows, exceptions and permissions;
  • define measurable quality goals.

Architecture

  • choose the simplest deployable shape that meets current needs;
  • keep business rules independent of frameworks;
  • separate commands from queries when their responsibilities differ;
  • create module boundaries based on business ownership;
  • document dependency rules and trade-offs.

Delivery

  • build a thin end-to-end path first;
  • validate at boundaries and enforce invariants in the domain/database;
  • make authorization, audit and errors part of every slice;
  • design read models for user decisions;
  • include failure, empty and concurrent cases.

Quality

  • test valuable behaviour at the cheapest reliable level;
  • integration-test framework and persistence boundaries;
  • use property tests for broad invariants;
  • review performance with representative data;
  • treat accessibility and security as acceptance criteria.

Sustainability

  • establish a Definition of Done;
  • document how to extend the system;
  • govern shared components;
  • automate build and test checks;
  • report completed and planned work honestly;
  • collect operational evidence after deployment.

22. Closing perspective

BuildEstate Pro is valuable as more than a collection of technologies. .NET 8, ASP.NET Core, Entity Framework Core, MediatR, SQL Server, Angular 20, NgRx, Tailwind CSS and DaisyUI are capable tools, but the learning sits in the connections between them.

The project shows how a business vision becomes bounded modules; how scenarios become commands, queries and state transitions; how a browser action travels through an effect, API, validation pipeline, domain model and database; how platform services prevent each module reinventing security and audit; and how tests and documentation keep an expanding system understandable.

Mastery does not mean memorising every pattern. It means being able to explain the problem a pattern solves, recognise its costs, implement it clearly and remove it when it no longer earns its place.

If you take one habit from this case study, make it this: build one honest, end-to-end business outcome, make its rules visible, prove it works, document what you learned, and then repeat. That is how large software is developed—one controlled slice at a time.

23. Mentoring walkthrough: design one land-acquisition slice

Let us make the approach concrete by mentoring a junior developer through one workflow: recording a potential development site and moving it into initial assessment. This is an illustrative walkthrough grounded in the documented Land Acquisition module; it is not a claim that every code fragment below is copied unchanged from the repository.

Begin with the business conversation

Junior: “I have the wireframe for the Create Opportunity screen. Shall I create the Angular component and match the fields?”

Senior: “The wireframe is useful, but first tell me what business outcome the screen begins.”

The junior might answer: “It adds a property opportunity.” That is still too broad. We continue asking:

  • Who may create an opportunity?
  • What makes two opportunities the same?
  • Which information is required at discovery time?
  • Can an incomplete opportunity be saved?
  • What state does a new record enter?
  • Who owns the first assessment?
  • Which actions must be audited?
  • What happens if the same site is submitted twice?
  • Does creation trigger notifications or search indexing?
  • Which facts are sensitive?
These questions prevent the form from becoming the accidental domain model. A screen groups information for a human task; the domain protects rules across every entry point, including APIs, imports and future integrations.

Suppose the discovery conversation produces this scenario:

An authorised acquisitions user records a site opportunity with a working title, address, source and estimated site area. The system assigns an opportunity number, records the creator and time, enters the opportunity into Identified, writes an audit record and makes it available to permitted search users. An existing active opportunity for the same external source reference must not be duplicated.
Now we have a slice with actors, inputs, output, starting state, audit, search and one uniqueness rule.

Define vocabulary before classes

The team should agree whether “site,” “land parcel,” “opportunity” and “development” mean different things. In this example:

  • a site opportunity is a potential acquisition under investigation;
  • a site address describes its known location and may be incomplete early on;
  • a source reference identifies the originating agent, listing or internal lead;
  • an assessment records commercial and planning investigation;
  • an acquired site is a later outcome, not simply a Boolean flag.
Junior: “Why spend time on words when the database can be changed later?”

Senior: “Because ambiguous words spread into routes, classes, permissions, reports and conversations. Renaming a column is easy compared with correcting five teams that mean different things by Project.”

Write the glossary beside the workflow documentation and use it in code. If stakeholders disagree, expose the disagreement early rather than hiding it behind a generic LandRecord entity.

Shape the command around intent

A command should express the requested business action, not expose an entity for arbitrary editing:

public sealed record IdentifySiteOpportunityCommand(
    string WorkingTitle,
    SiteAddressInput Address,
    decimal? EstimatedAreaHectares,
    OpportunitySource Source,
    string? ExternalSourceReference)
    : IRequest<Result<IdentifySiteOpportunityResponse>>;

There is no Status, CreatedBy, CreatedAt or OpportunityNumber in the client command. The server owns those values. Accepting them from the browser would make the API wider and less trustworthy than necessary.

Validation at the request boundary can provide helpful feedback:

public sealed class IdentifySiteOpportunityValidator
    : AbstractValidator<IdentifySiteOpportunityCommand>
{
    public IdentifySiteOpportunityValidator()
    {
        RuleFor(x => x.WorkingTitle)
            .NotEmpty()
            .MaximumLength(160);

        RuleFor(x => x.EstimatedAreaHectares)
            .GreaterThan(0)
            .When(x => x.EstimatedAreaHectares.HasValue);

        RuleFor(x => x.ExternalSourceReference)
            .MaximumLength(100);
    }
}

Boundary validation protects input shape and gives clients useful messages. Domain invariants still belong in domain behaviour, and database constraints protect rules under concurrency. One validator cannot replace the other layers.

Make creation a domain operation

Instead of exposing public property setters, the aggregate can establish a valid starting state:

public sealed class SiteOpportunity : AggregateRoot
{
    private SiteOpportunity() { }

    public OpportunityNumber Number { get; private set; } = default!;
    public string WorkingTitle { get; private set; } = string.Empty;
    public SiteAddress Address { get; private set; } = default!;
    public OpportunityStatus Status { get; private set; }
    public UserId OwnerId { get; private set; }

    public static Result<SiteOpportunity> Identify(
        OpportunityNumber number,
        string workingTitle,
        SiteAddress address,
        UserId creatorId)
    {
        if (string.IsNullOrWhiteSpace(workingTitle))
            return Result.Invalid("A working title is required.");

        var opportunity = new SiteOpportunity
        {
            Id = SiteOpportunityId.New(),
            Number = number,
            WorkingTitle = workingTitle.Trim(),
            Address = address,
            Status = OpportunityStatus.Identified,
            OwnerId = creatorId
        };

        opportunity.Raise(new SiteOpportunityIdentified(
            opportunity.Id,
            opportunity.Number,
            creatorId));

        return opportunity;
    }
}

The example makes the starting state explicit and raises an event describing something that happened. It does not send an email from the entity or call a search service. The domain records the fact; application and infrastructure code decide how to react.

Junior: “Isn't this more code than setting five properties?”

Senior: “Yes. The question is whether the extra code protects meaningful rules. If creation has no behaviour, a simple model may be enough. Here the named operation protects status, identity and the event consistently.”

Do not manufacture aggregates full of ceremonial methods. Encapsulation earns its cost when it prevents invalid state or makes important transitions visible.

Authorise at the use-case boundary

Hiding the Create button is good user experience, but it is not security. The handler must verify the authenticated user has the required permission within the relevant organisation or project scope.

public async Task<Result<IdentifySiteOpportunityResponse>> Handle(
    IdentifySiteOpportunityCommand command,
    CancellationToken cancellationToken)
{
    var actor = currentUser.RequireAuthenticated();

    if (!await authorisation.CanAsync(
        actor,
        Permissions.Land.Opportunities.Create,
        cancellationToken))
    {
        return Result.Forbidden();
    }

    // Remaining orchestration follows here.
}

A permission catalogue avoids scattered magic strings, but naming a permission is not enough. Tests must prove its scope. A user allowed to create opportunities for Company A must not gain that ability for Company B merely because their token contains a broad role name.

Treat duplicate prevention as a concurrency problem

The handler can check whether an external reference already exists and return a friendly conflict. That check improves the normal experience, but two requests can both pass it before either inserts. A database uniqueness constraint is the final guard.

For example, the database might enforce uniqueness on tenant, source and normalised external reference for active records. The exact index depends on deletion and archival rules. The application should catch the known constraint violation and translate it into a stable conflict response rather than exposing a SQL exception.

Junior: “If the user double-clicks Submit, can't we just disable the button?”

Senior: “Disable it to reduce accidents, but retries can come from browsers, proxies, mobile networks or impatient users. Correctness cannot depend on one button behaving perfectly.”

An idempotency key may be appropriate for APIs likely to be retried. Store the key and result within a defined scope and lifetime. Alternatively, a business-unique reference may naturally make repeated creation safe. The policy should be explicit.

Keep the transaction boundary understandable

The main state change and its durable audit/outbox records should succeed or fail coherently. A common shape is:

  1. authorise the actor;
  2. validate business preconditions;
  3. allocate an opportunity number safely;
  4. create the aggregate;
  5. persist it;
  6. persist domain events to an outbox;
  7. commit once;
  8. return the created representation.
Email delivery and search indexing should not hold the database transaction open. An outbox processor can publish the event after commit and retry safely. Consumers need idempotency because delivery can occur more than once.
await repository.AddAsync(opportunity, cancellationToken);
await outbox.AddAsync(opportunity.DomainEvents, cancellationToken);
await unitOfWork.SaveChangesAsync(cancellationToken);

The code is short; the operational contract is not. Define what happens when the commit succeeds but a notification provider is unavailable. The opportunity should remain created, the outbox should retain pending work, and monitoring should reveal repeated processing failure.

Return a representation designed for the next decision

After creation, the UI may need the generated number, current status, permissions and available actions. Return that small representation rather than serialising the tracked entity:

public sealed record IdentifySiteOpportunityResponse(
    Guid Id,
    string Number,
    string WorkingTitle,
    string Status,
    IReadOnlyCollection<string> AvailableActions);

AvailableActions can help the client render consistently, but the server must still authorise every later command. It is guidance for the interface, not a transferable permission grant.

Build the Angular form as a workflow surface

The form should distinguish draft input from confirmed server state. Typed reactive forms can make the input model clear:

readonly form = this.formBuilder.nonNullable.group({
  workingTitle: ['', [Validators.required, Validators.maxLength(160)]],
  addressLine1: ['', Validators.required],
  town: ['', Validators.required],
  postcode: [''],
  estimatedAreaHectares: this.formBuilder.control<number | null>(null),
  source: this.formBuilder.control<OpportunitySource>('Agent'),
  externalSourceReference: ['', Validators.maxLength(100)]
});

Client validation provides immediate help but does not need to duplicate every domain rule. On submission:

  • mark invalid controls and focus the error summary;
  • prevent accidental repeated submission while pending;
  • preserve entered values if the server rejects the request;
  • map field errors to controls and business conflicts to a page-level message;
  • announce success without unexpectedly moving focus;
  • navigate using the ID returned by the server, not a guessed route.
Junior: “Should NgRx own every keystroke?”

Senior: “Usually not. Temporary form input can remain in the form. Store state should represent information shared across routes or components, cached server results and meaningful workflow state.”

Putting every character into a global store adds actions and subscriptions without improving the business outcome. Conversely, keeping the created opportunity only in a deeply nested component may leave a shared list stale. Choose ownership from lifetime and consumers.

Design all visible states

The screen is incomplete if it has only a populated happy state. Specify:

  • initial loading;
  • permission denied;
  • empty reference-data lists;
  • validation failure;
  • duplicate opportunity conflict;
  • transient server failure;
  • successful creation;
  • successful creation with delayed secondary processing.
If search indexing is asynchronous, do not promise “available in search immediately.” The success message can say the opportunity was created and search may take a short time to update. Honest UI copy is part of distributed-system design.

Test the rule at the cheapest reliable level

The aggregate test proves the starting state and event:

[Fact]
public void Identify_starts_in_identified_state_and_records_event()
{
    var result = SiteOpportunity.Identify(
        OpportunityNumber.Parse("LAND-2026-0042"),
        "Canal Road site",
        TestAddresses.Valid,
        TestUsers.AcquisitionsOfficer);

    result.IsSuccess.Should().BeTrue();
    result.Value.Status.Should().Be(OpportunityStatus.Identified);
    result.Value.DomainEvents.Should().ContainSingle()
        .Which.Should().BeOfType<SiteOpportunityIdentified>();
}

Integration tests should prove permission scope, uniqueness under the real database and atomic persistence. Component tests should prove labels, error association, pending behaviour and response mapping. One end-to-end test can prove the critical authorised journey through the deployed boundaries.

Do not assert private method calls. Test observable contracts and important invariants. A mock reporting that AddAsync was called cannot prove the unique index exists or that a different tenant is excluded.

Instrument the slice before an incident

Useful telemetry might include:

  • land.opportunity.identify.duration;
  • result classification: success, invalid, forbidden, conflict, failure;
  • outbox age and retry count;
  • search-indexing delay;
  • duplicate-conflict frequency;
  • trace spans for database and event processing.
Avoid placing full addresses or user-entered notes into metrics labels. High-cardinality and sensitive values make telemetry expensive and risky. Logs can contain carefully selected identifiers under the application's data-handling policy; metrics should use bounded dimensions.

Review the slice as a team

At review time, walk from the user's click to the committed row and back:

  1. Which requirement does this slice satisfy?
  2. Where is the input treated as untrusted?
  3. Which permission and scope are checked?
  4. Which rule lives in the domain?
  5. Which rule is finally protected by the database?
  6. What makes a retry safe?
  7. Which changes share the transaction?
  8. What happens when notification or search fails?
  9. Which response states can the UI render?
  10. How will support trace a reported failure?
Junior: “This review takes longer than checking whether the code compiles.”

Senior: “Yes, because compilation proves syntax and types. The walkthrough checks whether the feature keeps its business and operational promises.”

24. Extending the slice into assessment

The next feature should not become a generic UpdateOpportunity endpoint. “Submit for initial assessment,” “Assign assessor,” “Record planning risk” and “Reject opportunity” are different intents with different permissions and transition rules.

Suppose only an Identified opportunity with sufficient location information may enter UnderAssessment:

public Result SubmitForAssessment(UserId actor)
{
    if (Status != OpportunityStatus.Identified)
        return Result.Conflict("Only an identified opportunity can be submitted.");

    if (!Address.HasAssessmentLocation)
        return Result.Invalid("Location information is incomplete.");

    Status = OpportunityStatus.UnderAssessment;
    Raise(new SiteOpportunitySubmittedForAssessment(Id, actor));
    return Result.Success();
}

The transition belongs near the state it protects. The handler still authorises, loads, orchestrates and commits. A transition table in documentation can show every current state, permitted action, target state, permission and side effect. Generate tests from that table where practical.

Ask what happens when two assessors act from stale screens. Use an optimistic concurrency token and return a conflict containing the current status. The UI should explain that the opportunity changed and reload the available actions. Silently applying an action to a different state is rarely acceptable.

Exercise for the reader

Design the RejectOpportunity slice without copying the create flow mechanically. Answer:

  • Is rejection terminal, reversible or followed by archival?
  • Is a reason required, and who may see it?
  • Which roles may reject at each state?
  • Does rejection release assignments or cancel tasks?
  • Which notifications are mandatory?
  • What must appear in the audit history?
  • Can the command be repeated safely?
  • How will reports distinguish rejected from withdrawn?
  • What concurrency token is required?
  • Which retention policy applies to the reason?
Then sketch the command, aggregate method, database protection, response states and tests. If you cannot explain where each rule belongs, return to the workflow before adding framework code.

This exercise captures the central BuildEstate lesson: enterprise features are not forms over tables. They are controlled changes to a business process, performed by authorised people, under concurrent and failure-prone conditions, with evidence that the outcome is correct.

25. Mentoring incident: the opportunity exists but search cannot find it

Imagine support reports that a newly created opportunity opens correctly from its direct link but does not appear in global search. This is a useful test of whether the team understands asynchronous consistency.

Junior: “The create request returned success, so should we tell the user to refresh?”

Senior: “First establish which promise succeeded. The transaction may have committed while the search projection is still pending or failed.”

Trace the opportunity ID through these stages:

  1. the API accepted the command;
  2. the opportunity and outbox record committed together;
  3. the outbox publisher claimed the message;
  4. the search consumer received it;
  5. the consumer created or updated the search document;
  6. the search provider made the document queryable;
  7. the requesting user had permission to see the result.
Each boundary needs evidence. A database row proves stage two, not stage six. A completed consumer log proves an attempt, not necessarily a queryable document. A search hit under an administrator account does not prove permission filtering works for the affected user.

Use stable identifiers and trace context across the event. Record bounded metrics for outbox age, processing failures and indexing delay. If the message is in a dead-letter state, capture the failure category without logging the full address or commercially sensitive description.

Suppose the consumer failed after indexing but before marking its message complete. It will receive the event again. The handler must therefore be idempotent:

public async Task Handle(
    SiteOpportunityIdentified message,
    CancellationToken cancellationToken)
{
    var document = await projection.BuildAsync(message.OpportunityId, cancellationToken);

    await searchIndex.UpsertAsync(
        document.Id,
        document,
        cancellationToken);
}

An upsert keyed by opportunity identity is safer than blindly appending another document. The exact guarantee depends on the provider, so test duplicate delivery and partial failure deliberately.

Junior: “Should creation fail whenever search is unavailable?”

Senior: “Usually no. Search is a derived capability. Holding the business transaction open couples availability and can leave users unable to record work during a search outage. But the product must communicate the consistency model and operations must detect when the delay becomes unacceptable.”

The UI might confirm that the opportunity was created and provide its direct link. If indexing normally completes within seconds, there may be no need for another status. If delays can be substantial and search is operationally critical, expose projection status or a support diagnostic rather than asking users to guess.

The corrective action depends on evidence. It could be repairing a poison message, renewing an expired credential, fixing a projection mapping, changing permission filters, or replaying an event safely. “Add a retry” is not a complete answer: define retryable failures, backoff, attempt limits, dead-letter handling, alert ownership and idempotency.

After recovery, write a regression test that simulates duplicate delivery and a test that proves the affected role can find only permitted opportunities. Add an operational alert for outbox age rather than relying on user reports. Finally, update the workflow documentation to state that primary storage is immediately consistent while global search is an eventually consistent projection.

This incident teaches a broader lesson. Distributed features are defined as much by their delayed and failed states as by their success path. A senior engineer helps the junior developer locate the authoritative state, trace each boundary and improve both recovery and user expectations without pretending that every subsystem commits atomically.

As a final practice, draw this incident as a timeline. Mark the database commit, outbox publication, delivery attempt, index update and user search. Beside every arrow write its timeout, retry owner, identifier and observable signal. Then remove one dependency at a time and describe what the user, support engineer and automated monitor would see. This simple exercise exposes assumptions that a polished architecture diagram often hides. It also creates practical questions for a readiness review: Can pending work be replayed? Can a duplicate corrupt the projection? Can support distinguish permission filtering from indexing delay? Does recovery require direct database editing? A system becomes more trustworthy when the team can answer those questions before an outage rather than improvising during one.

Keep the resulting timeline with the operational guide and revisit it whenever delivery guarantees, providers, permissions or recovery procedures change.

Explore the source material

Applied In

The thinking in this article has been applied throughout my enterprise portfolio, where architecture, workflows, permissions, notifications, reporting and modular design are all built around real business operations rather than isolated technical features.

View BuildEstate Pro →
Afzal Ahmed

Afzal Ahmed

Senior Full Stack Engineer & Technical Lead

A hands-on engineer with 15+ years in commercial software. I publish what I am studying, revising and testing so visitors can see both established experience and learning still in progress.

How would you approach this problem? I'd love to hear your thoughts or continue the discussion.

Connect on LinkedIn →