.NET Core  

Modernizing a Legacy Enterprise Application Using .NET 6 Microservices, React, EF Core and ActiveMQ

Enterprise application modernization is not simply about upgrading frameworks or moving an existing application to the cloud. The real challenge is redesigning the application so that business capabilities can evolve independently while maintaining reliable communication, data consistency, scalability, and operational control.

In one of the enterprise projects I worked on, we modernized a legacy platform used for end-to-end LNG trading lifecycle management.

The legacy application was designed as a standalone system. The modernized platform moved toward a web-based microservices architecture using React, .NET 6 Web API, EF Core, SQL Server, ActiveMQ, OpenShift on AWS, and Azure DevOps.

The application supported multiple business capabilities including Agreements, Nominations, Deal Checks, Cargo Operations, Administration, and Feature Management.

One particularly interesting aspect of the implementation was the contract-driven interaction between React and the .NET APIs.

For many business screens, an API returned a structured contract containing the information required by the page. React consumed this contract, maintained the page state, and sent the modified contract back to the API during save operations.

In this article, I will walk through this architecture and discuss some of the important design decisions behind it.

Architecture Overview

At a high level, the application followed this flow:

                       
ChatGPT Image Jul 27, 2026, 03_36_55 PM

The architecture can be viewed as several layers:

Frontend: React

Gateway: API Gateway

Backend: .NET 6 Web API microservices

Persistence: EF Core + SQL Server

Messaging: ActiveMQ

Container Platform: OpenShift on AWS

CI/CD: Azure DevOps

Rather than considering these technologies independently, the important part is understanding how they worked together.

Moving from a Standalone Application to Microservices

The legacy application handled multiple business capabilities within a standalone architecture.

As enterprise applications grow, this can make development and release management increasingly difficult because changes to one functional area may affect other parts of the application.

The modernized architecture separated major business capabilities into services.

For example:

                API Gateway
                     |
       +-------------+-------------+
       |             |             |
       v             v             v
 Agreements     Nominations      Cargo
   Service        Service       Operations
       |             |             |
       +-------------+-------------+
                     |
                  ActiveMQ

The objective was not simply to create multiple APIs.

A microservice should represent a meaningful business capability with a clear responsibility.

For an LNG trading platform, Agreements, Nominations, Deal Checks, and Cargo Operations represent distinct functional areas and therefore provide natural candidates for service boundaries.

This helps reduce coupling between business capabilities and allows teams to evolve different parts of the platform more independently.

API Gateway as the Application Entry Point

Instead of the React application directly managing the addresses and topology of multiple backend services, requests were routed through an API Gateway.

Conceptually

React

  |
  | HTTPS
  v

API Gateway

  |
  +----> Agreement Service
  |
  +----> Nomination Service
  |
  +----> Deal Check Service
  |
  +----> Cargo Operations Service

The API Gateway pattern provides a single entry point to backend services.

Depending on implementation requirements, a gateway can handle concerns such as:

  • Routing

  • Authentication and authorization integration

  • TLS termination

  • Request policies

  • Logging

  • Rate limiting

  • Service abstraction

This also prevents the frontend from needing detailed knowledge about the internal deployment topology of every service.

Contract-Driven Interaction Between React and .NET

One of the key implementation approaches in the application was the use of structured API contracts.

For many screens, the React application first requested the complete data required for the page.

The API returned a DTO or contract representing that page state.

For example:

public class AgreementContract
{
    public long AgreementId { get; set; }

    public string AgreementNumber { get; set; }

    public string Status { get; set; }

    public CounterpartyDto Counterparty { get; set; }

    public DateTime EffectiveDate { get; set; }

    public DateTime? ExpiryDate { get; set; }

    public List<AgreementTermDto> Terms { get; set; }

    public byte[] RowVersion { get; set; }
}

The frontend flow looked approximately like this:

1. React requests page

       GET /api/agreements/1024

                 |
                 v

2. API retrieves required data

                 |
                 v

3. API creates AgreementContract

                 |
                 v

4. React receives contract

                 |
                 v

5. User modifies page

                 |
                 v

6. React sends modified contract

       PUT /api/agreements/1024

                 |
                 v

7. API validates and persists changes

This creates an explicit boundary between the frontend and backend.

Why Use DTOs Instead of Exposing EF Entities?

A tempting implementation is to return EF Core entities directly from controllers.

For example:

[HttpGet("{id}")]
public async Task<Agreement> Get(long id)
{
    return await _dbContext.Agreements
        .FirstAsync(x => x.Id == id);
}

Although this can work for simple applications, it creates coupling between the API representation and persistence model.

Instead:

React
   |
   v
API Contract / DTO
   |
   v
Application Layer
   |
   v
Domain / Persistence Entity
   |
   v
EF Core
   |
   v
SQL Server

This separation gives us more control over what is exposed externally.

The database entity might contain internal properties that the frontend should never modify.

For example:

public class Agreement
{
    public long Id { get; set; }

    public string AgreementNumber { get; set; }

    public string Status { get; set; }

    public DateTime CreatedDate { get; set; }

    public string CreatedBy { get; set; }

    public DateTime ModifiedDate { get; set; }

    public string ModifiedBy { get; set; }

    public byte[] RowVersion { get; set; }
}

The frontend may need AgreementNumber, Status, and RowVersion, but it should not necessarily be allowed to modify fields such as CreatedBy.

DTOs therefore provide an important security and architectural boundary.

GET Contract Implementation

A simplified controller might look like this:

[ApiController]
[Route("api/agreements")]
public class AgreementsController : ControllerBase
{
    private readonly IAgreementService _agreementService;

    public AgreementsController(
        IAgreementService agreementService)
    {
        _agreementService = agreementService;
    }

    [HttpGet("{id:long}")]
    public async Task<ActionResult<AgreementContract>> Get(
        long id,
        CancellationToken cancellationToken)
    {
        var agreement =
            await _agreementService.GetAsync(
                id,
                cancellationToken);

        if (agreement == null)
            return NotFound();

        return Ok(agreement);
    }
}

The application service can retrieve the entity and map it to the API contract.

public async Task<AgreementContract?> GetAsync(
    long id,
    CancellationToken cancellationToken)
{
    var entity = await _dbContext.Agreements
        .AsNoTracking()
        .Include(x => x.Terms)
        .FirstOrDefaultAsync(
            x => x.Id == id,
            cancellationToken);

    if (entity == null)
        return null;

    return MapToContract(entity);
}

Notice the use of:

.AsNoTracking()

For read-only operations where EF does not need to subsequently update the loaded entities within the same DbContext, disabling tracking can reduce unnecessary tracking overhead.

React Consumes the Contract

The React application receives the contract and uses it as the state for the page.

A simplified TypeScript model could be:

export interface AgreementContract {
    agreementId: number;
    agreementNumber: string;
    status: string;
    effectiveDate: string;
    expiryDate?: string;
    counterparty: Counterparty;
    terms: AgreementTerm[];
    rowVersion: string;
}

The page retrieves the contract:

const loadAgreement = async (id: number) => {

    const response =
        await fetch(`/api/agreements/${id}`);

    if (!response.ok) {
        throw new Error("Unable to load agreement");
    }

    const contract =
        await response.json();

    setAgreement(contract);
};

React then renders the screen using the returned data.

When a user modifies a field:

const updateStatus = (status: string) => {

    setAgreement(prev => ({
        ...prev!,
        status
    }));
};

The contract therefore becomes the frontend's representation of the current page state.

Saving the Contract

When the user saves the page, React sends the updated contract back to the API.

const saveAgreement = async (
    agreement: AgreementContract) => {

    const response =
        await fetch(
            `/api/agreements/${agreement.agreementId}`,
            {
                method: "PUT",
                headers: {
                    "Content-Type": "application/json"
                },
                body: JSON.stringify(agreement)
            });

    if (!response.ok) {
        throw new Error("Unable to save agreement");
    }

    return await response.json();
};

The API receives the contract:

[HttpPut("{id:long}")]
public async Task<ActionResult<AgreementContract>> Update(
    long id,
    AgreementContract contract,
    CancellationToken cancellationToken)
{
    if (id != contract.AgreementId)
        return BadRequest();

    try
    {
        var result =
            await _agreementService.UpdateAsync(
                contract,
                cancellationToken);

        return Ok(result);
    }
    catch (DbUpdateConcurrencyException)
    {
        return Conflict(
            "The agreement has been modified by another user.");
    }
}

The API remains responsible for validation and business rules.

The frontend contract should never be considered trusted data simply because it was originally generated by the API.

Mapping Contract Changes to EF Core Entities

The application service can load the current entity and apply permitted changes from the contract.

public async Task<AgreementContract> UpdateAsync(
    AgreementContract contract,
    CancellationToken cancellationToken)
{
    var entity = await _dbContext.Agreements
        .Include(x => x.Terms)
        .FirstOrDefaultAsync(
            x => x.Id == contract.AgreementId,
            cancellationToken);

    if (entity == null)
        throw new KeyNotFoundException();

    ValidateAgreement(contract);

    entity.Status = contract.Status;
    entity.EffectiveDate = contract.EffectiveDate;
    entity.ExpiryDate = contract.ExpiryDate;

    entity.ModifiedDate = DateTime.UtcNow;
    entity.ModifiedBy = GetCurrentUser();

    // Apply permitted child changes here.

    _dbContext.Entry(entity)
        .Property(x => x.RowVersion)
        .OriginalValue = contract.RowVersion;

    await _dbContext.SaveChangesAsync(
        cancellationToken);

    return MapToContract(entity);
}

An important principle here is that the server decides which fields may be updated.

Avoid blindly mapping every incoming property onto the entity.

For example, doing something equivalent to:

_dbContext.Entry(entity).CurrentValues.SetValues(contract);

without controlling the mapping can allow fields to be modified unintentionally.

Explicit mapping provides more control over the update operation.

EF Core Change Tracking

EF Core uses a change tracker to maintain information about entities loaded into a DbContext.

For example:

var agreement =
    await _dbContext.Agreements
        .FirstAsync(x => x.Id == id);

agreement.Status = "Approved";

await _dbContext.SaveChangesAsync();

When the entity is loaded, EF Core tracks its original state.

After:

agreement.Status = "Approved";

EF can determine that Status changed.

Conceptually:

Original Entity

Status = Draft

       |
       | Application changes entity
       v

Current Entity

Status = Approved

       |
       v

EF Core Change Tracker

Status:
Draft ---> Approved

       |
       v

SaveChanges()

       |
       v

SQL UPDATE

This reduces the need to manually write SQL update statements for each operation.

Optimistic Concurrency with RowVersion

Contract-based screens introduce an important scenario.

Imagine User A and User B open the same agreement.

Database

Agreement 1024
Version = 15

Both users receive Version 15.

User A                    User B

Version 15                Version 15
   |                          |
   | Edit                     | Edit
   v                          v

User A saves first.

Database

Version 15
    |
    | User A saves
    v
Version 16

User B still has Version 15.

If User B now saves without a concurrency check, User A's changes could potentially be overwritten.

Optimistic concurrency helps detect this condition.

An EF entity can define:

public class Agreement
{
    public long Id { get; set; }

    public string Status { get; set; }

    [Timestamp]
    public byte[] RowVersion { get; set; }
}

Or configure it through Fluent API:

modelBuilder.Entity<Agreement>()
    .Property(x => x.RowVersion)
    .IsRowVersion();

EF Core can then generate an update conceptually similar to:

UPDATE Agreements
SET
    Status = @Status
WHERE
    Id = @Id
    AND RowVersion = @OriginalRowVersion;

If the version no longer matches, no row is updated.

EF Core can raise:

DbUpdateConcurrencyException

The application can then decide whether to:

  • Inform the user that the record changed

  • Reload the latest version

  • Display differences

  • Allow the user to reapply the changes

  • Implement a business-specific conflict-resolution strategy

This prevents a silent last-write-wins approach for important business data.

Contract Versioning vs Data Concurrency Versioning

These two concepts are sometimes confused.

API versioning controls changes to the external API contract.

For example:

/api/v1/agreements
/api/v2/agreements

Data versioning or optimistic concurrency determines whether a particular record changed since the client retrieved it.

For example:

AgreementId = 1024
RowVersion  = 0x000000000000081A

They solve completely different problems.

API versioning protects API consumers from incompatible contract changes.

Row versioning protects data from conflicting updates.

Synchronous Communication

Some operations require an immediate response.

For example:

React
   |
   | HTTP
   v
API Gateway
   |
   v
Agreement Service
   |
   v
Response

HTTP request/response communication is appropriate when the caller requires an immediate result.

However, forcing every interaction between microservices to be synchronous can increase runtime coupling.

If Service A cannot complete because Service B is temporarily unavailable, failures can propagate through the system.

This is where asynchronous messaging becomes valuable.

Asynchronous Messaging with ActiveMQ

ActiveMQ was used for asynchronous communication between services.

Conceptually:

Agreement Service
       |
       | Publish Message
       v
    ActiveMQ
       |
       +----------------+
       |                |
       v                v
Service B           Service C

Suppose an agreement reaches an important business state.

Instead of directly calling several downstream services, the originating service can publish an event.

For example:

public record AgreementApprovedEvent(
    long AgreementId,
    DateTime ApprovedDate,
    string ApprovedBy);

A simplified publisher abstraction could be:

public interface IMessagePublisher
{
    Task PublishAsync<T>(
        T message,
        CancellationToken cancellationToken);
}

The application can then publish:

await _messagePublisher.PublishAsync(
    new AgreementApprovedEvent(
        entity.Id,
        DateTime.UtcNow,
        currentUser),
    cancellationToken);

Other services interested in that event can consume it independently.

This reduces direct runtime dependencies between the producer and consumers.

Messaging Reliability

Introducing a message broker solves some problems but introduces new ones.

Consider:

Database Save
     |
     | Successful
     v

Publish ActiveMQ Message
     |
     X
Broker unavailable

The database transaction may succeed while message publishing fails.

Now the business state has changed, but downstream services were never informed.

This is a classic distributed-system consistency problem.

A common solution is the Transactional Outbox Pattern.

Conceptually:

              Same Database Transaction

                    +-----------+
                    |
                    v
              Business Data
API -------->      +
              Outbox Message
                    |
                    v
                  COMMIT

                    |
                    v

             Outbox Processor
                    |
                    v
                 ActiveMQ
                    |
                    v
                 Consumer

Instead of attempting to atomically coordinate SQL Server and ActiveMQ, the service stores the outgoing event in an outbox table as part of the same database transaction as the business change.

A background process subsequently publishes the event.

Whether an outbox is necessary depends on the business requirements and the guarantees required by the application, but it is an important pattern to consider for critical messaging workflows.

Idempotent Message Consumers

Messaging systems may deliver the same message more than once depending on acknowledgement, retry, and failure scenarios.

Consumers should therefore be designed with duplicate processing in mind.

Suppose a message contains:

{
    "messageId": "55cb54bb-9fb7-45c8-a02f",
    "eventType": "AgreementApproved",
    "agreementId": 1024
}

A consumer can track processed message IDs.

Receive Message
      |
      v
Has MessageId already
been processed?
      |
  +---+---+
  |       |
 Yes      No
  |       |
  v       v
Ignore   Process
          |
          v
      Record MessageId

This is an example of designing the consumer for idempotency.

Exactly how this is implemented depends on the business operation and messaging infrastructure.

Feature Toggle Architecture

The application also included Feature Toggle capabilities.

A feature toggle allows functionality to be controlled through configuration rather than requiring code to be redeployed every time a feature needs to be activated or deactivated.

For example:

public class FeatureConfiguration
{
    public string FeatureName { get; set; }

    public bool Enabled { get; set; }
}

Application logic might conceptually perform:

if (await _featureManager
        .IsEnabledAsync("NewNominationWorkflow"))
{
    // New workflow
}
else
{
    // Existing workflow
}

This can be useful for:

  • Controlled feature rollout

  • Environment-specific functionality

  • Production validation

  • Emergency feature disablement

  • Gradual migration

  • Operational control

However, feature toggles should have a lifecycle.

A temporary toggle that remains indefinitely can create increasingly complicated branches:

if (FeatureA)
{
    if (FeatureB)
    {
        if (FeatureC)
        {
            // ...
        }
    }
}

Feature toggles therefore require governance just like application code.

Administration Module

The platform also provided administrative capabilities for managing operational configuration.

This is important in enterprise applications because not every configuration change should require a deployment.

Conceptually:

Administrator / DevOps

          |
          v

    Administration UI

          |
          v

      Admin API

          |
          v

Configuration / Feature Management

The key architectural requirement is to distinguish operational configuration from application code.

Security and auditing are also important.

Administrative changes should generally capture information such as:

Who changed it?
What changed?
When was it changed?
What was the previous value?

For business-critical systems, auditability becomes an architectural requirement rather than simply a logging feature.

Containerized Deployment Using OpenShift on AWS

The microservices were deployed through OpenShift in AWS.

Containerization allowed services to be packaged consistently.

A simplified lifecycle looked like:

.NET Source Code
      |
      v
Build
      |
      v
Automated Tests
      |
      v
Container Image
      |
      v
OpenShift Deployment
      |
      v
Running Microservice

OpenShift provides container orchestration capabilities based on Kubernetes and helps manage application workloads across environments.

The architecture benefits from containerization because each microservice can have its own:

  • Build

  • Runtime configuration

  • Deployment

  • Scaling policy

  • Resource allocation

  • Release lifecycle

This supports one of the key objectives of microservices: independent evolution of services.

CI/CD with Azure DevOps

Azure DevOps was used to automate the application delivery process.

A conceptual pipeline was:

Code Commit
     |
     v
Azure DevOps
     |
     +--> Restore
     |
     +--> Build
     |
     +--> Unit Test
     |
     +--> Code / Security Checks
     |
     +--> Package
     |
     +--> Build Container
     |
     v
Deploy to OpenShift

A simplified YAML example might look like:

trigger:
- main

pool:
  vmImage: ubuntu-latest

steps:

- task: UseDotNet@2
  inputs:
    packageType: sdk
    version: '6.x'

- script: dotnet restore
  displayName: Restore

- script: dotnet build --no-restore
  displayName: Build

- script: dotnet test --no-build
  displayName: Test

- script: dotnet publish -c Release
  displayName: Publish

The actual enterprise pipeline can be considerably more sophisticated, including quality gates, vulnerability scanning, artifact management, environment approvals, deployment strategies, and rollback mechanisms.

CI/CD is therefore not just an operational convenience.

In a microservices environment, it becomes an important architectural capability.

Patterns Used in the Solution

An architecture like this is not based on one design pattern.

It combines patterns and architectural concepts at different levels.

Microservices Architecture

Business capabilities are separated into independently manageable services.

API Gateway Pattern

Provides a common entry point for frontend/API traffic.

Contract-Driven API Design

React and .NET communicate through explicit request and response contracts.

Data Transfer Object (DTO) Pattern

External API representations remain separated from persistence entities.

Unit of Work

EF Core DbContext provides Unit-of-Work-like behaviour for tracking and persisting related changes.

Change Tracking

EF Core detects modifications made to tracked entities.

Optimistic Concurrency

Row versions can detect conflicting updates without pessimistically locking records for the entire user interaction.

Asynchronous Messaging

ActiveMQ enables services to communicate without requiring all workflows to execute synchronously.

Publish/Subscribe

Where events have multiple interested consumers, messaging can support event-oriented integration.

Feature Toggle Pattern

Features can be activated or deactivated independently from deployments.

Transactional Outbox

For workflows requiring reliable propagation of database changes to the message broker, the outbox pattern can address the database/message consistency gap.

Idempotent Consumer

Consumers can be designed to safely handle duplicate message delivery.

Is This CQRS?

A question that often comes up with architectures like this is whether separating GET and SAVE operations means the application uses CQRS.

Not necessarily.

Consider:

GET

Database
   |
   v
AgreementContract
   |
   v
React


SAVE

React
   |
   v
AgreementContract
   |
   v
Database

If the same conceptual model is being used for both reading and updating the resource, this is closer to a traditional contract-driven CRUD/service architecture.

CQRS deliberately separates the read and write models.

For example

                    Application

              +---------+---------+
              |                   |
              v                   v
          Query Side          Command Side

              |                   |
              v                   v
        AgreementView       ApproveAgreement
        AgreementList       UpdateAgreement

CQRS can be useful for some complex domains, but it should not be introduced merely because an application has GET and POST/PUT endpoints.

Architecture should solve business and technical problems rather than exist to satisfy pattern terminology.

A Potential Improvement: Separate Read and Update Contracts

Using one large contract for both loading and saving a page can simplify frontend development, particularly for complex enterprise forms.

However, it can also result in larger payloads and stronger coupling between the UI and API.

For example:

GET Agreement

Returns:
Agreement
Counterparty
Terms
Configuration
Permissions
Dropdowns
Metadata
Validation information
...

If React sends this entire structure back during every save, some data may be unnecessary for the update.

An alternative is:

GET

AgreementPageResponse
       |
       v
     React


SAVE

UpdateAgreementRequest
       |
       v
      API

For example:

public class UpdateAgreementRequest
{
    public long AgreementId { get; set; }

    public string Status { get; set; }

    public DateTime EffectiveDate { get; set; }

    public DateTime? ExpiryDate { get; set; }

    public byte[] RowVersion { get; set; }
}

The read response can contain everything needed to build the page, while the update request contains only information required to perform the operation.

Neither design is universally correct.

For complex forms, using a page-level contract can make development simpler. As the application evolves, separating read and write contracts can provide tighter boundaries and reduce over-posting.

Another Improvement: Business Commands

For some operations, updating a generic resource may not clearly express the business intention.

Consider:

PUT /api/agreements/1024

with:

{
    "status": "Approved"
}

Compare that conceptually with:

POST /api/agreements/1024/approve

and:

public record ApproveAgreementRequest(
    byte[] RowVersion);

The second approach communicates the business intention more explicitly.

The application can then enforce the rules associated with approval:

public async Task ApproveAsync(
    long agreementId,
    byte[] rowVersion,
    CancellationToken cancellationToken)
{
    var agreement =
        await GetAgreementAsync(
            agreementId,
            cancellationToken);

    if (agreement.Status != AgreementStatus.Ready)
        throw new BusinessRuleException(
            "Agreement must be ready before approval.");

    agreement.Approve();

    await _dbContext.SaveChangesAsync(
        cancellationToken);
}

For simple editing, CRUD-style APIs are perfectly reasonable.

For important business transitions, explicit commands can make the domain behaviour clearer.

Observability in Microservices

One area that becomes significantly more important after moving from a standalone system to microservices is observability.

In a monolithic application, a request may remain within one process.

In a distributed architecture:

React
  |
  v
Gateway
  |
  v
Service A
  |
  v
ActiveMQ
  |
  v
Service B
  |
  v
Database

A single business workflow can cross multiple processes.

Logs therefore need enough contextual information to trace the complete operation.

A correlation identifier can travel across the workflow:

CorrelationId:
a7f4c382-...

For example:

Gateway
CorrelationId = ABC123
        |
        v
Agreement Service
CorrelationId = ABC123
        |
        v
ActiveMQ Message
CorrelationId = ABC123
        |
        v
Nomination Service
CorrelationId = ABC123

This allows operations teams to reconstruct a distributed transaction across services.

Metrics, centralized logging, health checks, tracing, alerting, and dashboards become increasingly important as the number of services grows.

Important Trade-offs

Microservices provide significant architectural benefits, but they also introduce complexity.

Benefits

Business capability isolation

Changes in one functional area can be better isolated from unrelated areas.

Independent deployment

Services can potentially be released independently.

Scalability

Specific services can be scaled according to their workload.

Technology and implementation boundaries

Services expose contracts instead of implementation details.

Asynchronous processing

Messaging can decouple long-running or cross-service workflows.

Operational flexibility

Feature toggles and administration capabilities allow selected behaviour to be managed independently from deployments.

Challenges

Distributed debugging

A business workflow may cross several services and message queues.

Data consistency

Transactions across service boundaries become more difficult.

Contract evolution

Changing an API or message schema requires compatibility considerations.

Messaging reliability

Retries, duplicate delivery and failed messages must be handled.

Operational overhead

More services mean more deployments, logs, metrics, configuration and infrastructure.

Testing complexity

Integration and end-to-end testing become more important.

Microservices are therefore not automatically better than a monolith.

They are valuable when the scale and complexity of the business justify the additional distributed-system complexity.

Key Lessons from the Implementation

Working on this modernization reinforced several architectural lessons for me.

1. Define service boundaries around business capabilities

A microservice should not exist merely because a solution can be divided into multiple APIs.

Service boundaries should represent meaningful business responsibilities.

2. Treat API contracts as architectural boundaries

Contracts should be deliberately designed rather than exposing persistence models directly.

3. Never trust a returned contract during save

Even if the API originally produced the object, the client can modify any value before sending it back.

Validate permissions, business rules, identifiers, and allowed modifications on the server.

4. Concurrency matters for enterprise forms

Users may keep complex screens open for significant periods of time.

Optimistic concurrency helps prevent one user's changes from silently overwriting another's.

5. Messaging reduces coupling but introduces reliability concerns

ActiveMQ enables asynchronous communication, but message delivery, retries, idempotency, ordering, dead-letter handling, and monitoring must be considered.

6. Feature toggles need governance

Feature toggles provide operational flexibility, but temporary flags should not become permanent technical debt.

7. DevOps is part of the architecture

Microservices without automated build, testing, deployment, monitoring, and rollback processes can quickly become operationally expensive.

8. Modernization should be incremental

A legacy application does not necessarily need to be rewritten completely in one step.

Business capabilities can be identified and modernized incrementally while preserving continuity for critical operations.

Final Architecture

Putting the major concepts together:

                              USERS
                                |
                                v
                        +---------------+
                        |     React     |
                        +-------+-------+
                                |
                                | HTTPS
                                v
                        +---------------+
                        |  API Gateway  |
                        +-------+-------+
                                |
              +-----------------+-----------------+
              |                 |                 |
              v                 v                 v
       +-------------+   +-------------+   +-------------+
       | Agreements  |   | Nominations |   |    Cargo    |
       |   Service   |   |   Service   |   | Operations  |
       +------+------+   +------+------+   +------+------+
              |                 |                 |
              |        Asynchronous Messages     |
              +---------------+-----------------+
                              |
                              v
                        +------------+
                        |  ActiveMQ  |
                        +------------+

                    Persistence where required

                       +------------+
                       |  EF Core   |
                       +-----+------+
                             |
                             v
                       +------------+
                       | SQL Server |
                       +------------+


                  Operational Capabilities

             +-----------------------------+
             | Feature Toggle Management   |
             | Administration              |
             | Logging / Monitoring        |
             +-----------------------------+


                     Delivery Pipeline

Source Code
     |
     v
Azure DevOps
     |
     +--> Build
     +--> Test
     +--> Quality / Security
     +--> Package
     +--> Container Image
                |
                v
            OpenShift
                |
                v
               AWS

The exact database ownership and communication boundaries should be determined by the application's actual service boundaries and business requirements rather than assuming that every microservice automatically requires a separate physical database.

Conclusion

Modernizing a legacy enterprise application into microservices involves much more than splitting a large application into smaller APIs.

It requires decisions around service boundaries, API contracts, frontend state, persistence, concurrency, messaging, deployment, configuration, reliability, and observability.

In this implementation, React and .NET APIs communicated through structured contracts, EF Core provided persistence and change tracking, optimistic concurrency helped protect shared business records, ActiveMQ enabled asynchronous communication between services, and feature-management capabilities provided operational flexibility.

OpenShift on AWS provided the container platform, while Azure DevOps supported automated build and deployment processes.

One of the most important lessons from the implementation was that architecture should not be driven by patterns for their own sake.

Microservices, API gateways, DTOs, messaging, feature toggles, optimistic concurrency, and CI/CD are tools for solving different problems.

The value comes from understanding where each pattern helps, what trade-offs it introduces, and how those patterns work together to support the business system as a whole.

That is ultimately what successful modernization is about: not simply adopting newer technologies, but creating an architecture that is easier to evolve, operate, and maintain.