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:

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
Join the conversation! Your thoughts help the community grow.