An architectural case study of designing, building, and deploying a proprietary business platform without overengineering it.
Note: This case study intentionally omits proprietary business rules, workflows, domain terminology, screenshots, and production data. The architecture and examples have been generalized so the engineering decisions can be discussed without exposing information that could affect the product's future commercialization or the stakeholder's information.
TL;DR
I took a business idea from an open-ended problem to a production MVP that a real business can rely on for its daily operations. The goal was not to build the largest or most sophisticated architecture possible, but to establish clear boundaries and a foundation that can evolve into a larger product without starting over.
The solution uses a conventional web architecture—Vue, ASP.NET Core, PostgreSQL, and containerized deployment on DigitalOcean—combined with feature-oriented organization and pragmatic Clean/Onion Architecture. Along the way, I made deliberate decisions about authentication, authorization, testing, environments, health checks, infrastructure cost, deployment, and operational ownership.
The central lesson was simple: an MVP can be small in scope without being disposable in architecture.
The real challenge wasn't building the application
Recently, I took an internal product idea from a relatively open-ended business problem to a production system that real users could actually use.
The product is a small business platform for an optical laboratory. I am deliberately keeping the business side of the application out of this article. The solution is tailored to a specific operational domain and may eventually become a product that can be offered to other laboratories, so exposing the details of the workflows would defeat the purpose of writing about the engineering behind it.
What is interesting, however, is everything around those workflows.
The project required making architectural decisions with incomplete information, deciding where simplicity was more valuable than abstraction, establishing boundaries between the frontend, backend, and persistence layers, and then taking the resulting system through deployment, UAT, and its first production use.
That last part changed the nature of the project.
Building an MVP is relatively easy to define:
Get the important functionality working.
Building something that people can depend on is a different problem:
Make the system understandable, deployable, observable enough to operate, and safe enough to change.
That distinction became the central theme of this project.
There was another constraint that mattered just as much: this was an MVP, but it was never intended to be a disposable prototype.
The business needed to be able to rely on the system for its day-to-day operations. In practical terms, that means more than getting the main workflows to work. The system needs to provide an appropriate level of availability for its current operational needs, while the information it produces and stores needs to be consistent, trustworthy, and accessible.
That changes the engineering question.
The goal wasn't to build an enterprise platform before anyone had used it. It was to build an MVP with enough architectural discipline that it could become the foundation of a larger product without requiring the team to throw the first version away.
Starting with constraints, not technology
The first architectural decision was actually to avoid making technology the starting point.
The application had a fairly straightforward shape: a browser-based interface, a backend API, persistent relational data, authentication, and a small number of supporting infrastructure concerns.
That naturally led to a relatively conventional stack:
- ASP.NET Core / .NET 10 for the API
- Vue 3 + Vite for the web application
- PostgreSQL for persistence
- Entity Framework Core for data access
- Docker for packaging and deployment
- Nginx as the reverse proxy
- Playwright for end-to-end validation
- FluentValidation for request validation
- DigitalOcean for the initial hosting environment
The important decision wasn't selecting those technologies individually. It was deciding what not to introduce.
There was no compelling reason to turn a small application into a distributed system. No Kubernetes cluster was necessary. No message broker was necessary. No collection of microservices was necessary. There was no reason to introduce a complicated cloud topology just to make the architecture diagram more impressive.
The system needed clear boundaries and room to evolve, not architectural ceremony.
That led to a principle I tried to follow throughout the project:
Use the simplest architecture that preserves the options the product is likely to need.
That is very different from "build the simplest thing possible."
A deliberately boring architecture
The high-level architecture ended up looking roughly like this:
flowchart TB
U[Users] -->|HTTPS| W[Vue Web Application]
W -->|REST API| A[ASP.NET Core API]
A --> D[(PostgreSQL)]
subgraph Infrastructure
W
A
D
end
The simplified view hides quite a bit of the infrastructure and supporting tooling. The broader architecture looked more like this:
Figure 1 — OpticLab architecture overview
The production architecture keeps application boundaries explicit while avoiding infrastructure complexity that isn't justified by the current scale.
There is nothing particularly exotic about this diagram.
That is intentional.
A good architecture does not need to look large or complex to be valuable. In this case, the important part was creating boundaries that would remain useful as the application evolved.
The web application owns the user experience.
The API owns application behavior and security boundaries.
The database owns persistence and relational integrity.
Those responsibilities can evolve independently without requiring the entire system to become distributed.
Organizing the application around capabilities
Inside the backend, I chose a feature-oriented approach, using Clean/Onion Architecture as a set of principles rather than a checklist that had to be followed literally.
That distinction was deliberate.
It is easy to take an architecture such as Clean Architecture or Onion Architecture and turn it into a collection of rules that exist mainly because the pattern says they should. For this project, the goal was different: use the boundaries and conventions that provide real value, and adapt them to the size and needs of an MVP.
Instead of treating the application primarily as a collection of technical layers, the application is organized around capabilities.
A simplified representation looks like this:
Application
├── Authentication
├── Catalog
├── Workflows
└── ...
The exact domain names are intentionally omitted, but the important architectural idea remains.
The goal was to keep the code associated with a capability reasonably close together while maintaining a clear separation between the domain, application behavior, infrastructure, and API concerns.
This was particularly useful because business applications tend to change by capability.
A requirement rarely arrives as:
"Please modify the persistence layer."
It arrives as:
"We need this workflow to behave differently."
The architecture should make that kind of change easy to reason about.
At the same time, I deliberately avoided turning every boundary into another abstraction simply because the architecture diagram could support it.
There is a point where architectural patterns stop reducing complexity and start creating it.
For a system of this size, that distinction matters.
The same thinking influenced the decision not to introduce CQRS simply because the application was already using an Onion-style architecture.
CQRS can be a valuable architectural approach when the problem benefits from separate read and write models, independent scaling, or more sophisticated domain and integration patterns. In this case, introducing it would have added another layer of abstraction between the application's use cases and its data without solving a problem the MVP actually had.
The abstraction would have been more ceremonial than helpful.
So the architecture borrows from Onion and Clean Architecture where those ideas create useful boundaries, but it does not require every convention associated with those architectures to be implemented strictly for the sake of consistency with a pattern.
The real objective is a codebase that is easy to change today and capable of becoming the foundation of a larger product tomorrow.
If a new requirement arrives, the team should be able to quickly identify where that change belongs, understand the impact, implement it without unnecessary coupling, and extend the existing structure rather than recreate the project around a new architectural direction.
Sometimes a change that sounds simple in Jira becomes a whole user story with its own research task simply because the architecture makes it difficult to determine where the change belongs. That is where complexity can appear before a single line of code is written.
Architecture should be a facilitator, not another layer of complexity. That is what I consider a useful architecture for any system.
Making technology choices by trade-off
One of the more useful ways to evaluate architecture decisions is to ask what alternatives were considered.
For example, there were discussions around API approaches such as GraphQL and OData.
Both are legitimate technologies. Neither was inherently wrong for the problem.
But adding another query abstraction would have introduced additional concepts for the frontend, backend, authorization model, testing strategy, and long-term maintenance.
The question therefore wasn't:
"Which technology is more powerful?"
It was:
"What problem are we solving that justifies the additional complexity?"
For the current product, a conventional REST API was sufficient.
That same reasoning applied to the broader architecture.
| Decision | Direction | Primary consideration |
|---|---|---|
| Backend | ASP.NET Core | Strong platform support and a mature ecosystem |
| Frontend | Vue 3 | Productive SPA development with a clear component model |
| UI components | Vuetify | Consistent application UI without building everything from scratch |
| Database | PostgreSQL | Strong relational capabilities and portability |
| Data access | EF Core | Productive relational development with a mature .NET ecosystem |
| Architecture | Clean/Onion + feature-oriented organization | Boundaries without excessive ceremony |
| API style | REST | Sufficient for the product's current needs |
| Packaging | Docker | Repeatable environments and deployment |
| Hosting | DigitalOcean | Simple operational model appropriate for the MVP |
| Reverse proxy | Nginx | Clear entry point for HTTP/HTTPS traffic |
| E2E testing | Playwright | Validate important behavior from the user's perspective |
The important thing about this table is not the technology.
It is the decision-making model behind it.
Authentication is a system boundary, not a UI feature
Authentication was treated as a cross-cutting architectural concern rather than something the frontend could be responsible for.
The resulting flow is conceptually simple:
sequenceDiagram
participant User
participant Web as Web Application
participant API as API
participant DB as Database
User->>Web: Sign in
Web->>API: Credentials
API->>DB: Validate identity
DB-->>API: Identity + roles
API-->>Web: Authentication token
Web->>API: Authenticated request
API->>API: Validate identity + authorization
API-->>Web: Response
The frontend controls what the user sees and what interactions are available to them. We still implemented a second authorization layer in the backend.
That second layer serves two purposes.
The obvious one is security: the API must enforce the same authorization rules regardless of what the UI happens to expose.
The architectural benefit is just as important. It keeps the authorization model from being tightly coupled to the current frontend.
Today, the consumer of the API is the Vue application. There is no architectural reason that has to remain true forever. If the API is eventually consumed by another application, or if the current UI is replaced with something such as React, the same authorization rules should continue to apply.
The UI is therefore responsible for the user experience around authorization.
The API remains the authoritative security boundary.
That separation gives the frontend more freedom to evolve without forcing the backend's security model to evolve with it.
The database was designed for a product, not just an MVP demo
The persistence layer uses PostgreSQL and a relational model.
Again, there was no reason to introduce a second database technology simply because the application might grow.
The database was designed around the relationships and invariants that mattered to the domain while keeping the model intentionally isolated from the presentation layer.
The goal was to make the persistence model a replaceable implementation detail from the perspective of the application core, while still taking advantage of PostgreSQL's relational strengths.
That also influenced conventions around identifiers, naming, constraints, and migrations.
An MVP still benefits from good data integrity.
It is much cheaper to prevent invalid states than to discover them after the system has accumulated months of production data.
Testing from the user's perspective
One of the decisions I wanted to make early was that automated testing should validate behavior, not simply implementation details.
Unit and API-level tests are useful, but they do not answer the most important question:
Can a real user actually complete the workflow?
That's where Playwright became important.
The testing strategy therefore spans multiple levels:
flowchart LR
D[Domain / Application Logic] --> U[Unit & Integration Tests]
U --> A[API Behavior]
A --> E[End-to-End Scenarios]
E --> P[Production Confidence]
End-to-end scenarios provide a different kind of confidence from unit tests.
They validate the application as a system: browser, frontend, API, authentication, persistence, and the resulting user-visible behavior.
I don't consider end-to-end tests a replacement for lower-level tests.
I consider them the final layer that answers whether the pieces actually work together.
From application to system
At some point, the problem stopped being primarily about application code.
The application had to become deployable.
And that decision was not purely technical.
Infrastructure architecture is also shaped by the economics of the product. The stakeholder's budget matters. Operational complexity has a cost. Cloud resources have a cost. Engineering time has a cost.
From an architectural perspective, I wanted to answer three questions together:
- What infrastructure fits the stakeholder's budget and capabilities?
- Can that infrastructure support the product's needs with little ongoing technical intervention?
- If something goes wrong, how much time and expertise will it take an internal or external engineer to diagnose and fix it?
These questions are related. The right architecture is not simply the one with the strongest technical capabilities; it is the one whose operational cost remains appropriate for the product and its expected budget over the foreseeable future.
For this MVP, answering these questions from the start led to a clear path that defined not only which providers and resources to use, but also what the application architecture could reasonably support.
That led to a single DigitalOcean droplet, with the application components separated into containers.
DigitalOcean offered a straightforward operational model and a strong value-to-cost ratio for the current scale. The containers provided the separation and reproducibility we wanted without introducing the operational overhead of a larger orchestration platform.
In other words, the infrastructure was designed around both technical requirements and business constraints.
That is an important part of architecture that can be easy to overlook when architecture is discussed only as a technical exercise.
The initial hosting architecture deliberately stayed small:
flowchart TB
Internet --> DNS[Domain / DNS]
DNS --> HTTPS[HTTPS]
HTTPS --> N[Nginx Reverse Proxy]
N --> WEB[Web Container]
N --> API[API Container]
API --> DB[(PostgreSQL)]
The initial production environment is hosted on a DigitalOcean Ubuntu server.
The application components are containerized, with the web application and API deployed as separate containers and PostgreSQL providing persistence.
Nginx provides the public HTTP/HTTPS entry point.
The domain is managed through Porkbun, with TLS termination handled at the edge of the application environment.
This architecture is intentionally modest.
A single-server deployment would be a poor answer for some systems. For this product, at its current scale, it provides a much smaller operational surface while still giving us clear separation between the major components.
The important part is that the containers provide logical separation even though the infrastructure underneath them is intentionally small.
That gives us a useful middle ground: we are not paying the operational cost of a distributed platform before the product requires it, but we are also not treating the application as one undifferentiated process that can only be operated as a whole.
The important part is recognizing that this is a current architectural decision, not a permanent commitment.
If the product's scale or availability requirements change, the architecture can change with it.
Environment separation matters more than environment count
The project also needed to distinguish between development, UAT, and production.
That sounds trivial until you have a production user looking at a screen and asking:
"Is this the real system?"
A small visual environment indicator and application version/build information can eliminate a surprising amount of operational ambiguity.
The broader principle is more important than the implementation:
An environment should identify itself.
This is particularly useful during UAT, deployment verification, support, and troubleshooting.
It also creates a simple relationship between a deployed application and the source/build that produced it.
The health endpoint: a small feature with an operational purpose
Another example of the application crossing into infrastructure concerns was the health endpoint.
The requirement was intentionally minimal.
The API exposes an anonymous health endpoint that can answer whether the application process is healthy enough for infrastructure to consider it alive.
It was deliberately kept independent of external dependencies such as the database or email provider.
That was an important distinction.
A liveness check should not become a complicated business diagnostic endpoint.
The goal was to make it useful to:
- the container runtime
- deployment infrastructure
- monitoring
- future CI/CD verification
Conceptually:
GET /health
│
▼
Application running?
│
┌───┴───┐
│ │
Yes No
│ │
HTTP 200 Failure
It is a tiny endpoint, but it represents a broader principle:
Applications should expose the signals their infrastructure needs to operate them safely.
The repository is part of the architecture
One decision that became increasingly important as the project grew was treating the repository as more than a place to store application source code.
The repository is intended to be the project's single source of truth for the things a contributor needs to understand, build, test, and operate the system.
The structure is roughly:
opticlab/
├── README.md
├── docs/
│ ├── architecture/
│ │ ├── ARCHITECTURE.md
│ │ ├── database-schema.yaml
│ │ └── plans/
│ └── deployment/
├── infrastructure/
│ ├── docker-compose.yml
│ ├── docker-compose.uat.yml
│ ├── docker-compose.prod.yml
│ ├── env/
│ ├── nginx/
│ └── scripts/
├── src/
│ ├── OpticLab.API/
│ ├── OpticLab.Application/
│ ├── OpticLab.Domain/
│ ├── OpticLab.EmailTemplates/
│ ├── OpticLab.Infrastructure/
│ └── OpticLab.UI/
├── tests/
│ ├── e2e/
│ └── OpticLab.API.Tests/
└── tools/
└── test-data/
The exact implementation details are not important to the article. The architectural idea is.
Architecture decisions, development plans, deployment configuration, container definitions, persistence definitions and reference data, environment templates, infrastructure scripts, application code, and automated tests live together under version control.
That creates a useful property for both current and future contributors:
The repository describes not only how to build the application, but how to understand and operate it.
It also makes the project easier to hand over.
A contributor should not need to reconstruct architectural decisions from conversations or discover deployment conventions by trial and error. The relevant decisions and operational assets should be available alongside the code.
For a small team, that is a meaningful form of operational resilience.
Looking inside the backend
The high-level architecture is intentionally simple, but the backend still has meaningful internal boundaries.
A useful way to describe them is:
flowchart TB
API[API / Presentation]
APP[Application]
DOMAIN[Domain]
INFRA[Infrastructure]
API --> APP
APP --> DOMAIN
APP --> INFRA
INFRA --> DOMAIN
The layers have different responsibilities:
| Area | Responsibility |
|---|---|
| API / Presentation | HTTP endpoints, request handling, authentication configuration, and translating external requests into application operations |
| Application | Use cases, orchestration, validation, application-level behavior, and feature organization |
| Domain | Core business concepts and rules that should remain independent of infrastructure |
| Infrastructure | Persistence, authentication implementation, external services, and other technical concerns |
This is not an attempt to create four independently deployable systems.
The boundaries exist to control dependencies and make changes easier to reason about.
The feature-oriented organization sits within those boundaries, so a new capability can be developed without turning the entire codebase into a search exercise.
That combination—architectural boundaries plus feature-oriented organization—was more useful for this MVP than following a single architecture pattern rigidly.
CI/CD: automate the path, not the complexity
The project is also being prepared for CI/CD through Buddy.
Buddy was selected because the goal is not to build a CI/CD platform; it is to have a reliable, repeatable path from source control to deployment.
The important qualification is that this pipeline is planned, not yet implemented. The production system was brought online before the Buddy pipeline was put in place, so this is the next evolution of the delivery process rather than something I am presenting as already completed.
The intended pipeline is conceptually:
flowchart LR
G[Git Repository] --> B[Buddy Pipeline]
B --> C[Build]
C --> T[Automated Tests]
T --> I[Container Images]
I --> D[Deployment]
D --> H[Health Verification]
The plan is to define the pipeline declaratively, most likely using Buddy's YAML-based configuration rather than relying exclusively on the visual wizard.
That choice is deliberate.
A UI is excellent for discovering a platform.
A version-controlled configuration is generally better for an engineering system.
A pipeline represented as code can be reviewed, versioned, changed alongside the application, and reproduced rather than existing only as configuration hidden inside a third-party dashboard.
The eventual pipeline should make the deployment path explicit:
- Build the application.
- Run automated validation.
- Produce deployable artifacts/images.
- Deploy the selected environment.
- Verify that the application is healthy.
The objective isn't to create the most elaborate pipeline possible.
It is to remove unnecessary manual decisions from deployment.
Configuration and secrets are deployment concerns
Moving from local development to UAT and production also forced a distinction between application configuration and application code.
Environment-specific values should not be embedded into the application.
That includes things such as:
- database connection information
- authentication secrets
- environment-specific configuration
- external service credentials
The application is therefore packaged independently from its deployment configuration.
This seems obvious, but it is one of the easiest boundaries to blur during rapid MVP development.
Keeping it clean early makes the transition between environments much less painful.
Email is an infrastructure dependency too
Email was another example of a concern that starts outside the core application but quickly becomes part of the production architecture.
During development, SMTP testing could be handled locally.
For production, the project uses an external transactional email provider rather than attempting to operate an email server.
The architectural reasoning is straightforward:
Email delivery is not a capability I want this application to own.
The application should request that an email be sent.
Deliverability, reputation, retries, DNS authentication, and provider infrastructure belong to a specialized service.
For production, Resend was selected as the provider.
Again, this is less about the particular vendor and more about maintaining a sensible boundary around infrastructure responsibility.
The hardest part: going live without breaking the system
The first production deployment is where an MVP becomes real.
Until that point, many architecture decisions are hypothetical.
After users start relying on the system, they become operational decisions.
The deployment therefore wasn't treated as a single event.
The system was first prepared for UAT, exposed to the stakeholder, validated in an environment resembling production, and then moved toward its first real usage.
That process highlighted something that is easy to underestimate:
Deployment is part of the product.
A system that works perfectly on a developer's machine but requires a developer to manually reconstruct the environment every time it needs to be deployed is not operationally mature.
Likewise, a system with excellent code but no way to tell which version is running creates unnecessary uncertainty for everyone involved.
The engineering work therefore extended beyond application functionality.
What I intentionally did not build
One of the most important architectural decisions in this project was knowing when to stop.
There are many things that could be added.
Kubernetes could be introduced.
Infrastructure could be fully managed through Terraform.
A larger observability stack could be deployed.
The system could be split into more services.
A more sophisticated deployment topology could be created.
None of those are inherently bad ideas.
They simply weren't justified by the current requirements.
This is an important distinction in architecture:
Future scalability is not the same thing as present complexity.
The goal was to leave room for those changes without paying their operational cost prematurely.
What comes next
Going live doesn't mean the architecture is finished.
It means the feedback loop has changed.
The next stage is less about proving that the system can work and more about making it increasingly reliable and easier to operate.
Areas for future evolution include:
- richer application logging and observability
- stronger deployment verification
- more explicit rollback mechanisms
- infrastructure-as-code as the environment grows
- stronger backup and disaster-recovery practices
- more mature secret management
- additional automated operational checks
- broader production monitoring
These are not signs that the initial architecture was incomplete.
They are the natural next steps of a system moving from MVP toward a more mature product.
What I learned from building it
1. An MVP still deserves architectural discipline
MVP should describe the scope of the product, not the quality of its engineering.
There is a difference between deliberately building less and deliberately building badly.
2. Architecture is mostly about trade-offs
The most valuable architectural decisions were often the ones where I decided not to introduce something.
Not every application needs microservices.
Not every API needs GraphQL.
Not every deployment needs Kubernetes.
The architecture should follow the constraints of the system rather than the popularity of the technology.
3. Operational concerns should enter the design early
Health checks, environment identification, configuration, deployment, testing, and observability are much easier to introduce when the architecture already has clear boundaries for them.
They become painful when they're treated as things to "add later."
4. A deployment pipeline is part of the engineering product
The application isn't the only thing that needs to be repeatable.
The path that produces and deploys the application should be repeatable too.
That is why the next step with Buddy is not simply "configure a pipeline."
It is to make deployment itself a versioned engineering artifact.
5. Simplicity is an architectural capability
The final system is not simple because the engineering problems were simple.
It is simple because complexity was introduced selectively.
That distinction matters.
A good architecture should make the system easier to change, not merely make the architecture diagram more impressive.
AI as an engineering aid
AI was also used during the project as an engineering aid: helping analyze trade-offs, review approaches, and generate or refine parts of the code.
I don't want that to become the focus of this case study.
The more interesting question is how AI fits into an engineering process where architectural decisions, constraints, validation, and accountability still belong to the engineer.
That is a topic large enough for its own article, and I plan to cover it separately rather than turn this case study into an AI-development retrospective.
Closing
This project started as a small application intended to solve a specific business problem.
By the time it reached its first production users, it had become something more interesting from an engineering perspective.
It required decisions about architecture, boundaries, authentication, data, testing, environments, containers, networking, deployment, configuration, health checks, and operational ownership.
None of those decisions are particularly revolutionary on their own.
The value came from putting them together deliberately.
For me, that is the more useful definition of a production-ready MVP:
Not a system with every possible enterprise feature, but a system whose complexity is intentional, whose boundaries are understandable, and whose path from code to production is something the engineering team can control.
And now that the first users are on the system, the next phase begins: learning what the production environment teaches us and evolving the architecture based on evidence rather than assumptions.

Top comments (0)