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:
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.