← Back to list

Beyond Coding: Architecture, CI/CD, Monorepo, and UAT Lessons for Growing Engineering Teams

A practical guide for developers who want to understand how software architecture, testing, deployment, and team workflows affect real produ

Raylabs · 2026-05-28 08:24 · 0 claps · 13.7 min read paywalled
#software-engineering #cicd #monorepo #clean-architecture #testing
Open on Medium ↗
Wiki topics: 💻 · Programming ☁️ · DevOps & Cloud 🏛️ · Architecture

Beyond Coding: Architecture, CI/CD, Monorepo, and UAT Lessons for Growing Engineering Teams

A growing engineering team planning software architecture on a board, with visual elements such as Clean Architecture layers, CI/CD pipeline, Monorepo packages, Turbo cache, UAT checklist, and production release gate.

A growing engineering team planning software architecture on a board, with visual elements such as Clean Architecture layers, CI/CD pipeline, Monorepo packages, Turbo cache, UAT checklist, and production release gate.

Introduction: Good Software Is Not Only About Code That Works

When we are still learning to code, the goal often feels simple.

Make the feature work.

Fix the bug.

Pass the test.

Deploy the app.

But as projects grow, software quality becomes more complicated than that.

A feature can work today but become hard to change next month.

A build can pass locally but fail in the pipeline.

A frontend can be deployed successfully but become incompatible with the latest backend API.

A product can pass all automated tests but still fail during real user validation.

This is where engineering starts to go beyond coding.

Architecture matters.

Repository strategy matters.

CI/CD design matters.

Testing strategy matters.

UAT matters.

Team workflow matters.

And the bigger the team or product becomes, the more expensive bad engineering decisions can be.

This article is part of my beginner-friendly web development and software engineering series. The previous articles covered web development foundations, localhost and dynamic websites, frontend mistakes, and modern web app architecture.

Now, we move into a more advanced but very practical area: how architecture, monorepo tooling, CI/CD, and UAT affect real product quality.

Not from a purely theoretical perspective, but from the perspective of a developer who wants to build software that can survive real team collaboration.

1. Technical Debt Usually Starts Small

Technical debt is not always created by one big bad decision.

Sometimes it starts with small shortcuts.

A class that does too many things.

A global Singleton that becomes hard to mock.

A shared type copied manually between frontend and backend.

A deployment process that depends on someone remembering the correct order.

A test case that does not reflect real user behavior.

At first, these shortcuts may feel harmless.

The project still runs.

The feature still works.

The deadline is saved.

But later, when the team needs to change something quickly, the cost appears.

Technical debt is the cost of poor technical decisions that make software harder to maintain, refactor, test, or scale.

It is not only about messy code.

It can also come from weak architecture, unclear ownership, poor test coverage, outdated documentation, fragile deployment processes, or ignored user validation.

A useful way to think about technical debt is this:

The code works today, but the team pays interest every time they need to change it.

That is why architecture and workflow decisions matter early.

Not because every project needs enterprise-level complexity from day one, but because ignoring basic engineering discipline can make growth painful.

2. SOLID Is About Managing Change

SOLID principles are often introduced as object-oriented design rules.

But the real value of SOLID is not memorizing the acronym.

The real value is managing change.

A good design makes future changes safer.

A poor design makes every change risky.

Single Responsibility Principle

The Single Responsibility Principle says that a class should have one main reason to change.

For example, imagine a UserService that does all of this:

Validate user input
Save user data to database
Send confirmation email

At first, it may look convenient.

But it mixes different responsibilities.

Validation rules can change.

Database logic can change.

Email templates can change.

If all of them live inside one class, the class becomes harder to test and maintain.

A cleaner structure could be:

UserValidator
UserRepository
EmailService
UserService

Now each part has a clearer responsibility.

Open/Closed Principle

The Open/Closed Principle says software entities should be open for extension but closed for modification.

In simple terms, when adding new behavior, we should avoid constantly modifying stable code.

Instead, we can extend behavior through abstraction, strategy, composition, or new implementations.

This reduces the risk of breaking existing behavior.

Dependency Inversion Principle

The Dependency Inversion Principle says high-level business logic should not depend directly on low-level details.

For example, a use case should not be tightly coupled to a specific database driver or email provider.

It should depend on an abstraction.

This makes the system easier to test and easier to change later.

SOLID is not about making code look more “enterprise”.

It is about reducing the cost of change.

SOLID as change management.

SOLID as change management.

3. Dependency Injection Is Usually Safer Than Global Singleton

Singleton and Dependency Injection can both provide access to shared resources.

For example, a database connection or configuration service.

But they create very different design consequences.

A Singleton often provides one global instance.

class DatabaseConnection {
    static instance;
    static getInstance() {
        if (!DatabaseConnection.instance) {
            DatabaseConnection.instance = new DatabaseConnection();
        }
        return DatabaseConnection.instance;
    }
}

This can be useful in some cases.

But it can also create hidden dependencies and global state.

When a class reaches out to a Singleton directly, its dependency is not obvious from the outside.

That makes testing harder.

Refactoring becomes harder.

Replacing the implementation becomes harder.

Dependency Injection takes a different approach.

Instead of a class creating or fetching its own dependency, the dependency is provided from the outside.

class UserService {
    constructor(userRepository, emailService) {
        this.userRepository = userRepository;
        this.emailService = emailService;
    }
}

Now the dependencies are visible.

In tests, we can inject fake implementations.

In production, we can inject real implementations.

This makes the code more flexible and testable.

That is why Dependency Injection is often preferred in advanced architecture.

Not because Singleton is always evil.

But because hidden global dependencies can become expensive as the system grows.

4. Clean Architecture Protects Business Rules

Clean Architecture is built around one important rule: dependencies should point inward.

The business rules should not depend on frameworks, databases, UI, or external services.

Instead, outer layers depend on inner layers.

A simplified structure may look like this:

Frameworks and Drivers
Interface Adapters
Use Cases
Entities

The outer layer can know about the inner layer.

But the inner layer should not know about the outer layer.

This is called the Dependency Rule.

Why does this matter?

Because business rules should survive technology changes.

If today we use MongoDB, tomorrow we might use PostgreSQL.

If today we use Express, tomorrow we might use another backend framework.

If today we send email through one provider, tomorrow we might change provider.

The core business logic should not be rewritten just because the framework changes.

That is the point of Clean Architecture.

It keeps the important rules independent from external details.

This also improves testing because use cases can be tested without running the real database, web server, or third-party services.

5. Design Patterns Are Reusable Solutions, Not Decorations

Design patterns are reusable solutions to common design problems.

They are not rules that must be forced into every codebase.

They are tools.

Use them when the problem actually fits.

Factory Pattern

Factory Pattern is a creational pattern.

It helps create objects without tightly coupling the code to specific concrete classes.

For example, in an e-commerce system, different product types may need different object creation logic.

function createProduct(type, data) {
    if (type === "digital") {
        return new DigitalProduct(data);
    }
    if (type === "physical") {
        return new PhysicalProduct(data);
    }
    throw new Error("Unknown product type");
}

Observer Pattern

Observer Pattern defines a one-to-many relationship.

When the subject changes, observers are notified.

In Node.js, EventEmitter can be used for this style of design.

For example, when payment succeeds, several services may need to react.

paymentService.emit("paymentSuccess", paymentData);

Then other services can listen:

paymentService.on("paymentSuccess", sendEmail);
paymentService.on("paymentSuccess", writeLog);

This keeps the payment process decoupled from every side effect.

PaymentService does not need to directly call every service.

It emits an event.

Observers react.

MVVM and ViewModel

In MVVM, the ViewModel handles presentation logic and helps connect the View with the Model.

It is especially useful when the UI needs to react to state changes.

The ViewModel helps prepare data for the View without putting too much logic directly into the UI.

Again, the point is separation of responsibility.

Design patterns are valuable when they reduce coupling and make change easier.

They become harmful when used only to make code look advanced.

6. Monorepo vs Polyrepo Is a Strategic Decision

Repository strategy is not just a tooling preference.

It affects collaboration, release coordination, dependency management, and operational risk.

Monorepo

A monorepo stores multiple projects in one Git repository.

For example:

apps/web
apps/admin
services/api
packages/design-system
packages/catalog-types

The biggest advantage is coordination.

If frontend, backend, and shared types need to change together, they can be updated in one atomic commit.

For example, if catalog-types.ts changes, both frontend and backend can be updated and validated together.

This reduces the risk of incompatible changes.

Polyrepo

Polyrepo stores projects in separate repositories.

This is useful when teams or business units need stronger isolation.

For example, a healthcare business unit may require strict security audit and access control, while a game business unit may need a faster release cycle.

Keeping them in separate repositories can make sense.

The trade-off

Monorepo helps with coordinated changes and shared code.

Polyrepo helps with isolation and independent ownership.

Neither is always better.

The right choice depends on how teams work, how releases are managed, and how much code is shared.

Monorepo vs Polyrepo decision.

Monorepo vs Polyrepo decision.

7. PNPM Makes Monorepos More Predictable

In JavaScript monorepos, dependency management can become messy.

One common problem is phantom dependencies.

A phantom dependency happens when code uses a package that is not explicitly declared in its own package.json, but still works because the package exists somewhere else in node_modules.

This is dangerous because the project may work on one machine but fail somewhere else.

PNPM helps reduce this problem through strict dependency resolution.

A package can only access dependencies that it explicitly declares.

If a workspace needs jest, it should declare it.

For example, to add jest only to service-auth as a dev dependency:

pnpm --filter service-auth add -D jest

To run lint across all workspaces:

pnpm -r run lint

PNPM also uses a content-addressable store and hard links to save disk space.

Instead of copying the same package many times, PNPM can share one stored package copy efficiently.

pnpm.overrides

Sometimes different packages need different versions of a third-party library, causing peer dependency conflicts.

In that case, pnpm.overrides in the root package.json can force a compatible version.

{
    "pnpm": {
        "overrides": {
            "some-library": "^2.0.0"
        }
    }
}

This gives the team more control over dependency consistency.

PNPM is not just about faster installs.

It also makes dependencies more explicit and predictable.

8. Turbo Keeps Monorepo Builds Fast

As a monorepo grows, build time can become a serious problem.

If every small change triggers every package to rebuild, the pipeline becomes slow.

Turbo helps by understanding the relationship between tasks and workspaces.

This is called a Task Graph.

For example:

packages/design-system → apps/web
packages/catalog-types → apps/web
packages/catalog-types → services/api

If design-system changes, Turbo knows which apps depend on it.

It does not need to rebuild unrelated services.

Caching

Turbo can cache task outputs.

If a build output already exists and the input has not changed, Turbo can reuse the cache instead of rebuilding.

Remote caching makes this even more powerful across a team or CI environment.

If one developer or one CI run already built something, another run can reuse the cached result.

That is why Turbo builds can feel almost instant when configured correctly.

inputs and outputs matter

If Turbo cache is not working properly, one of the first things to check is turbo.json.

A build task should correctly define inputs and outputs.

{
    "tasks": {
        "build": {
            "inputs": ["$TURBO_DEFAULT$"],
            "outputs": ["dist/**", ".next/**"]
        },
        "test": {
            "outputs": []
        }
    }
}

If outputs are missing or wrong, Turbo may not cache correctly.

If inputs are incomplete, Turbo may incorrectly reuse cache.

For example, if a Next.js frontend build depends on a backend config file that contains API endpoint URLs, that config should be included as an input.

{
    "tasks": {
        "build": {
            "inputs": [
                "$TURBO_DEFAULT$",
                "../backend/config/api-endpoints.ts"
            ],
            "outputs": [".next/**"]
        }
    }
}

This ensures frontend cache is invalidated when the backend config changes.

Task dependencies

If deploy should only run after build, and build should only run after test, the dependency is:

test → build → deploy

In concept, deploy depends on build, and build depends on test.

This protects the pipeline from deploying untested or unbuilt code.

Turbo task graph and caching.

Turbo task graph and caching.

9. Large Monorepos Need Git Optimization Too

Monorepos can become large.

When that happens, Git operations such as status, checkout, and clone can become slower.

One troubleshooting option is Sparse Checkout.

Sparse Checkout allows developers to work with only specific subdirectories instead of the entire repository content.

For example, if a developer only works on:

services/api
packages/catalog-types

They do not always need the full repository checked out locally.

Sparse Checkout can reduce the amount of data downloaded and improve local workflow for very large repositories.

This does not replace good repository design.

But it is a useful tool when the monorepo becomes large enough to affect daily development speed.

10. CI Is Not the Same as Deployment

CI/CD terms are often mixed together.

But they mean different things.

Continuous Integration focuses on automatically checking code changes.

When a developer pushes code or opens a Pull Request, the pipeline can run linting, tests, and build checks.

The goal is to detect problems early.

Continuous Delivery means the application is always in a releasable state, but production deployment still needs manual approval.

Continuous Deployment means every change that passes the pipeline can be automatically deployed to production.

The difference matters.

For example, in an e-commerce backend with financial features, manager approval may be required before releasing to production.

In that case, Continuous Delivery is safer than Continuous Deployment.

The pipeline can automate checks and staging deployment, but production release still waits for a manual approval gate.

Automation does not always mean removing human control.

Sometimes the best pipeline automates verification but keeps approval where business risk is high.

11. A Safe CI/CD Pipeline Has a Logical Order

A basic GitHub Actions job for a monorepo should follow a safe sequence.

A logical order is:

Git Checkout
PNPM Install
PNPM Test
Turbo Run Build

Why this order?

First, checkout gets the latest code.

Then PNPM installs dependencies.

Then tests verify behavior.

Then Turbo builds the affected workspaces and prepares cacheable outputs.

A simplified example:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
      - name: Setup PNPM
        uses: pnpm/action-setup@v4
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
      - name: Run tests
        run: pnpm -r run test
      - name: Build with Turbo
        run: pnpm turbo run build

This is not a full production pipeline, but the sequence is important.

Do not build before dependencies are installed.

Do not deploy before tests and build pass.

Do not treat CI/CD as just a YAML file.

It is an engineering safety system.

12. Deploying Monorepos to Vercel Requires Clear Project Roots

If a monorepo contains frontend and backend workspaces, deployment needs careful configuration.

For example:

apps/frontend
apps/backend
packages/shared

If you want to deploy the frontend and backend separately to Vercel, a common approach is creating two Vercel projects.

One Vercel project points to the frontend root directory.

apps/frontend

Another Vercel project points to the backend or serverless function workspace.

apps/backend

This allows each project to have its own build command, environment variables, and deployment configuration.

The monorepo stays unified in Git, but deployment can still be separated by workspace.

This is one of the practical benefits of monorepo tooling: shared code and coordinated commits, but still flexible deployment boundaries.

13. UAT Validates Product Fit, Not Just Technical Correctness

Automated tests are important.

Unit tests verify small logic.

Integration tests verify that components work together.

End-to-end tests verify user flows.

But UAT checks something different.

User Acceptance Testing validates whether the application meets real business needs and user workflows.

That is why skipping UAT can be risky even when all technical tests pass.

A feature can be technically correct but still wrong for users.

It may not match their workflow.

It may miss an important business rule.

It may use terminology users do not understand.

It may fail with real-world data.

It may solve the wrong pain point.

A useful way to say it:

Passing automated tests means the software works as coded. Passing UAT means the software works for the people who actually use it.

That difference matters.

14. Good UAT Starts with Clear Scope and Acceptance Criteria

UAT becomes messy when the scope is unclear.

If everyone has a different understanding of what should be tested, the team may run irrelevant test cases or miss important scenarios.

That is why the UAT Plan should clearly define scope.

What is included?

What is excluded?

Which business flows are being validated?

Which user roles are involved?

Which data should be used?

Acceptance criteria should also be specific and testable.

For example, a weak user story says:

As a user, I want to upload files so that I can store my documents.

Without acceptance criteria, this is too vague.

A better acceptance criteria set could include:

User can upload a file successfully.
System validates allowed file formats.
System rejects files above the maximum size.
Uploaded file remains available after page refresh.
System shows a clear error message when upload fails.

Now QA, developer, and user have a clearer shared expectation.

Ambiguous acceptance criteria create different interpretations.

Different interpretations create conflict during UAT.

15. UAT Issue Logs Need Reproduction Steps

When a tester finds a bug, the developer needs to reproduce it.

A weak issue log says:

Upload does not work.

This is not enough.

A useful issue log includes reproduction steps.

1. Login as user A.
2. Open Document Upload page.
3. Select PDF file larger than 10 MB.
4. Click Upload.
5. Observe that the loading indicator never stops.

It should also include expected result and actual result.

Expected result:
System rejects the file and shows maximum size error message.
Actual result:
Loading indicator continues indefinitely.

This helps developers investigate faster.

Severity and status are important too, but reproduction steps are often the most helpful field for fixing the bug.

16. Real-World Scenarios and Production-Like Data Matter

UAT should be as close as possible to real usage.

If UAT uses unrealistic data, the result can be misleading.

For example, a system may work with simple test data:

User: John Doe
File: test.pdf
Amount: 1000

But production users may have long names, special characters, large files, unusual workflows, edge cases, or incomplete data.

If UAT does not reflect real-world scenarios, bugs may appear only after go-live.

This is dangerous for stakeholders because the application may look ready during testing but fail in real operations.

That is why UAT should include realistic user flows and representative data.

Not necessarily real sensitive production data, but data that behaves like production.

The closer the test scenario is to real usage, the more useful UAT becomes.

UAT validation flow.

UAT validation flow.

17. UAT Should Not Replace System Testing

If UAT produces many bugs that should have been caught earlier, that is a signal.

It may mean system testing was not strong enough.

UAT should focus on validating business requirements and real user workflows.

It should not become the first place where basic technical issues are discovered.

If too many technical defects leak into UAT, the team should evaluate the QA process before the next UAT cycle.

Possible improvements include:

Improving system test coverage.

Reviewing test case quality.

Adding regression tests.

Clarifying acceptance criteria earlier.

Improving developer testing before handoff.

Tracking defect leakage from system testing to UAT.

UAT is valuable, but it should not be used as a replacement for proper QA.

Good software quality does not come from one tool.

It does not come only from using React.

Or PNPM.

Or Turbo.

Or Vercel.

Or GitHub Actions.

Or Clean Architecture.

Or UAT.

Each of those helps, but only when used as part of a bigger engineering system.

Architecture helps manage change.

SOLID helps reduce maintenance risk.

Dependency Injection improves testability.

Clean Architecture protects business rules.

Monorepo helps coordinate related changes.

Polyrepo helps isolate independent domains.

PNPM keeps dependencies explicit.

Turbo keeps builds efficient.

CI/CD automates quality checks and deployment flow.

Continuous Delivery keeps manual approval where business risk requires it.

UAT validates whether the product actually works for real users.

The bigger lesson is this:

Good software is not only code that runs.

Good software is code that can be changed, tested, deployed, validated, and trusted by the team and the users.

That is what engineering beyond coding is really about.


메타데이터
post_id
da747bf5d2c6
slug
beyond-coding-architecture-ci-cd-monorepo-and-uat-lessons-for-growing-engineering-teams-da747bf5d2c6
url
https://medium.com/@raylabs/beyond-coding-architecture-ci-cd-monorepo-and-uat-lessons-for-growing-engineering-teams-da747bf5d2c6
canonical_url
https://medium.com/@raylabs/beyond-coding-architecture-ci-cd-monorepo-and-uat-lessons-for-growing-engineering-teams-da747bf5d2c6
author_url
https://medium.com/@raylabs
status
ok
fetched_at
2026-07-10 09:52:19