← Back to list

Designing a Modular Monolith Production-Ready Backend in .NET

An end-to-end practical architecture walkthrough using modular monoliths, CQRS, domain events, resiliency, and observability

Oshadha Thrimavithana · 2026-05-06 03:01 · 3 claps · 13.4 min read
#software-architecture #backend-development #domain-driven-design #dotnet #design-systems
Open on Medium ↗
Wiki topics: PRD · Product Design 🌐 · Web Development 🏛️ · Architecture

Designing a Modular Monolith Production-Ready Backend in .NET

An end-to-end practical architecture walkthrough using modular monoliths, CQRS, domain events, resiliency, and observability

Designing a Production-Ready Telemedicine Backend in .NET

An end-to-end practical architecture walkthrough using modular monoliths, CQRS, domain events, resiliency, and observability

Building a telemedicine platform is not just about creating CRUD APIs.

At first, it may look simple:

  • patients book consultations
  • doctors accept appointments
  • payments are processed
  • video calls happen
  • prescriptions are generated

But once you look closer, the system becomes much more interesting from an architecture point of view.

A real telemedicine backend needs to handle scheduling, doctor-patient workflows, payments, notifications, video sessions, prescriptions, auditability, security, failure handling, and production monitoring.

That makes telemedicine a good practical example for backend architecture.

In this article, I will walk through how I would design a production-ready telemedicine backend in .NET using:

  • modular monolith architecture
  • building blocks projects
  • CQRS-style commands and queries
  • domain events
  • clean module boundaries
  • synchronous and asynchronous workflows
  • resiliency and observability patterns

The goal is not to make the architecture look complicated.

The goal is to make it understandable, safe, and able to evolve.

Companion GitHub Repository

I have also created a companion GitHub repository for this article, with a starter .NET modular monolith template that demonstrates the same ideas in code.

You can find it here: [GitHub Repository]

The repository includes BuildingBlocks, module boundaries, CQRS-style commands and queries, domain events, outbox, idempotency, and a simplified consultation workflow.

The practical use case

We will design one end-to-end flow:

  1. A patient books a consultation
  2. A doctor accepts it
  3. The patient completes payment
  4. A notification is sent
  5. The video consultation starts
  6. The consultation is completed
  7. A prescription PDF is generated

This single flow is enough to demonstrate the main architectural decisions that matter in a real backend system.

Functional requirements

For this example, the system should support:

  • patients browsing doctors
  • patients booking consultations
  • doctors accepting or declining consultation requests
  • paid consultations requiring successful payment
  • patient and doctor notifications
  • video session creation
  • consultation completion
  • prescription generation
  • auditable consultation lifecycle

These are the business capabilities the backend must support.

But functional requirements alone are not enough.

A production system also needs strong non-functional qualities.

Non-functional requirements

The system should also support:

  • clear module boundaries
  • strong business consistency on writes
  • secure access by role
  • asynchronous follow-up workflows
  • resilient failure handling
  • observability for production troubleshooting
  • auditability for sensitive actions
  • future evolution without a full redesign

These requirements influence the architecture more than the endpoints do.

This is why we should not start by asking:

Should this be microservices?

A better starting question is:

What boundaries does this system need?

Why I would start with a modular monolith

For this kind of system, I would start with a modular monolith.

Not because microservices are bad.

But because microservices introduce distributed complexity very early:

  • service-to-service communication
  • network failures
  • retries
  • timeouts
  • distributed tracing
  • eventual consistency
  • deployment coordination
  • contract versioning

In the early and middle stages of a product, the domain is usually still evolving.

The team is still learning:

  • where the boundaries are
  • which workflows change frequently
  • which modules need to scale independently
  • which parts deserve isolation later

A modular monolith gives us a better starting point.

It allows us to create strong internal boundaries while keeping deployment, debugging, testing, and operations simpler.

The principle is:

Become modular before becoming distributed.

High-level project structure

For a serious modular monolith, I prefer using BuildingBlocks projects together with independent business modules.

A possible structure looks like this:

src/
  TeleMed.Api/

  TeleMed.BuildingBlocks/
    TeleMed.BuildingBlocks.Domain/
    TeleMed.BuildingBlocks.Application/
    TeleMed.BuildingBlocks.Infrastructure/

  TeleMed.Modules/
    Scheduling/
      TeleMed.Scheduling.Api/
      TeleMed.Scheduling.Application/
      TeleMed.Scheduling.Domain/
      TeleMed.Scheduling.Infrastructure/

    Consultation/
      TeleMed.Consultation.Api/
      TeleMed.Consultation.Application/
      TeleMed.Consultation.Domain/
      TeleMed.Consultation.Infrastructure/

    Payments/
      TeleMed.Payments.Api/
      TeleMed.Payments.Application/
      TeleMed.Payments.Domain/
      TeleMed.Payments.Infrastructure/

    Notifications/
      TeleMed.Notifications.Api/
      TeleMed.Notifications.Application/
      TeleMed.Notifications.Domain/
      TeleMed.Notifications.Infrastructure/

    Prescription/
      TeleMed.Prescription.Api/
      TeleMed.Prescription.Application/
      TeleMed.Prescription.Domain/
      TeleMed.Prescription.Infrastructure/

This structure gives us two important things:

  1. shared technical abstractions through BuildingBlocks
  2. business isolation through modules

The important rule is:

BuildingBlocks should contain reusable technical abstractions, not business concepts.

Good BuildingBlocks:

ICommand
IQuery
IDomainEvent
AggregateRoot
DomainException
IUnitOfWork
IRequestDispatcher

Bad BuildingBlocks:

Doctor
Patient
Appointment
Consultation
PaymentStatus
PrescriptionRules

Business concepts should stay inside their own modules.

What belongs in BuildingBlocks?

BuildingBlocks.Domain

This contains common domain primitives.

Examples:

  • Entity
  • AggregateRoot
  • ValueObject
  • IDomainEvent
  • DomainException

Example:

public interface IDomainEvent
{
    DateTime OccurredAtUtc { get; }
}

public abstract class AggregateRoot
{
    private readonly List<IDomainEvent> _domainEvents = new();

    public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents;

    protected void AddDomainEvent(IDomainEvent domainEvent)
    {
        _domainEvents.Add(domainEvent);
    }

    public void ClearDomainEvents()
    {
        _domainEvents.Clear();
    }
}

public sealed class DomainException : Exception
{
    public DomainException(string message) : base(message)
    {
    }
}

This code is not specific to telemedicine.

That is why it belongs in BuildingBlocks.

BuildingBlocks.Application

This contains application-level abstractions.

Examples:

  • ICommand
  • ICommandHandler
  • IQuery
  • IQueryHandler
  • IRequestDispatcher
  • validation behavior
  • transaction behavior
  • logging behavior

Example:

public interface ICommand<TResult>
{
}

public interface ICommandHandler<TCommand, TResult>
    where TCommand : ICommand<TResult>
{
    Task<TResult> Handle(TCommand command, CancellationToken cancellationToken);
}

public interface IQuery<TResult>
{
}

public interface IQueryHandler<TQuery, TResult>
    where TQuery : IQuery<TResult>
{
    Task<TResult> Handle(TQuery query, CancellationToken cancellationToken);
}

This gives us a CQRS-style application flow without forcing a specific library.

Commands change state.

Queries read data.

That separation keeps the application easier to reason about.

BuildingBlocks.Infrastructure

This can contain reusable infrastructure patterns such as:

  • outbox support
  • correlation ID handling
  • clock abstraction
  • base EF Core configurations
  • transaction behaviors
  • logging helpers
  • background job abstractions

But this project must be controlled carefully.

It should not become a dumping ground.

Main modules / bounded contexts

A telemedicine backend can be divided into several modules.

Identity

Owns authentication, authorization, and role-based access.

It answers questions like:

  • Who is this user?
  • Is this user a doctor or a patient?
  • Is this user allowed to perform this action?

Profiles

Owns doctor and patient profile information.

It includes:

  • doctor name
  • specialization
  • qualifications
  • patient basic profile
  • profile image
  • public doctor listing details

Scheduling

Owns availability and appointment booking rules.

It answers:

  • Is the doctor available?
  • Can this patient book this slot?
  • Has the slot already been reserved?
  • Is the booking time valid?

Consultation

Owns the lifecycle of the medical consultation.

It handles states like:

  • requested
  • accepted
  • awaiting payment
  • active
  • completed
  • cancelled

This module should protect the consultation state transitions.

Payments

Owns payment state and payment confirmation.

It handles:

  • payment requests
  • payment references
  • payment confirmation
  • duplicate payment protection
  • payment status

Notifications

Owns user-facing communication.

It handles:

  • appointment confirmations
  • payment reminders
  • doctor acceptance notifications
  • consultation start reminders

This module is a strong candidate for asynchronous processing.

Prescription

Owns prescription details and PDF generation.

It handles:

  • prescription creation
  • medication details
  • dosage instructions
  • doctor notes
  • PDF generation

End-to-end flow

Now let’s walk through the practical flow.

Step 1: Patient books a consultation

The patient selects a doctor and a time slot.

This is a write operation, so we model it as a command.

public sealed record BookConsultationCommand(
    Guid PatientId,
    Guid DoctorId,
    DateTime ScheduledAtUtc
) : ICommand<Guid>;

The handler coordinates the use case.

public sealed class BookConsultationHandler
    : ICommandHandler<BookConsultationCommand, Guid>
{
    private readonly IDoctorScheduleRepository _scheduleRepository;
    private readonly IConsultationRepository _consultationRepository;
    private readonly IUnitOfWork _unitOfWork;

    public BookConsultationHandler(
        IDoctorScheduleRepository scheduleRepository,
        IConsultationRepository consultationRepository,
        IUnitOfWork unitOfWork)
    {
        _scheduleRepository = scheduleRepository;
        _consultationRepository = consultationRepository;
        _unitOfWork = unitOfWork;
    }

    public async Task<Guid> Handle(
        BookConsultationCommand command,
        CancellationToken cancellationToken)
    {
        var schedule = await _scheduleRepository.GetByDoctorIdAsync(
            command.DoctorId,
            cancellationToken);

        if (schedule is null)
            throw new DomainException("Doctor schedule was not found.");

        schedule.EnsureSlotIsAvailable(command.ScheduledAtUtc);

        var consultation = Consultation.Request(
            command.PatientId,
            command.DoctorId,
            command.ScheduledAtUtc);

        await _consultationRepository.AddAsync(consultation, cancellationToken);
        await _unitOfWork.SaveChangesAsync(cancellationToken);

        return consultation.Id;
    }
}

Notice the separation:

  • The handler coordinates the use case
  • The domain model protects business rules
  • The repository handles persistence
  • The unit of work commits the transaction

The handler should not become the place where every business rule lives.

Step 2: Consultation aggregate protects state

The consultation lifecycle is not just a database status column.

It is business behavior.

For example, a consultation should not be completed before it becomes active.

A doctor should not accept a consultation that has already been cancelled.

A payment should not be requested for a consultation that is not accepted.

This is where an aggregate helps.

public sealed class Consultation : AggregateRoot
{
    public Guid Id { get; private set; }
    public Guid PatientId { get; private set; }
    public Guid DoctorId { get; private set; }
    public DateTime ScheduledAtUtc { get; private set; }
    public ConsultationStatus Status { get; private set; }

    private Consultation()
    {
    }

    private Consultation(Guid patientId, Guid doctorId, DateTime scheduledAtUtc)
    {
        Id = Guid.NewGuid();
        PatientId = patientId;
        DoctorId = doctorId;
        ScheduledAtUtc = scheduledAtUtc;
        Status = ConsultationStatus.Requested;

        AddDomainEvent(new ConsultationRequestedDomainEvent(
            Id,
            PatientId,
            DoctorId,
            DateTime.UtcNow));
    }

    public static Consultation Request(
        Guid patientId,
        Guid doctorId,
        DateTime scheduledAtUtc)
    {
        if (scheduledAtUtc <= DateTime.UtcNow)
            throw new DomainException("Consultation must be scheduled in the future.");

        return new Consultation(patientId, doctorId, scheduledAtUtc);
    }

    public void Accept()
    {
        if (Status != ConsultationStatus.Requested)
            throw new DomainException("Only requested consultations can be accepted.");

        Status = ConsultationStatus.Accepted;

        AddDomainEvent(new ConsultationAcceptedDomainEvent(
            Id,
            PatientId,
            DoctorId,
            DateTime.UtcNow));
    }

    public void MarkAwaitingPayment()
    {
        if (Status != ConsultationStatus.Accepted)
            throw new DomainException("Only accepted consultations can await payment.");

        Status = ConsultationStatus.AwaitingPayment;
    }

    public void Start()
    {
        if (Status != ConsultationStatus.PaymentConfirmed)
            throw new DomainException("Payment must be confirmed before starting.");

        Status = ConsultationStatus.Active;
    }

    public void Complete()
    {
        if (Status != ConsultationStatus.Active)
            throw new DomainException("Only active consultations can be completed.");

        Status = ConsultationStatus.Completed;

        AddDomainEvent(new ConsultationCompletedDomainEvent(
            Id,
            PatientId,
            DoctorId,
            DateTime.UtcNow));
    }
}

The important part is not the code style.

The important part is the responsibility.

The aggregate protects valid business transitions.

External code cannot freely set the status to whatever it wants.

Step 3: The doctor accepts the consultation

Doctor acceptance is another command.

public sealed record AcceptConsultationCommand(
    Guid ConsultationId,
    Guid DoctorId
) : ICommand;

The handler loads the aggregate and calls the business operation.

public sealed class AcceptConsultationHandler
    : ICommandHandler<AcceptConsultationCommand>
{
    private readonly IConsultationRepository _repository;
    private readonly IUnitOfWork _unitOfWork;

    public AcceptConsultationHandler(
        IConsultationRepository repository,
        IUnitOfWork unitOfWork)
    {
        _repository = repository;
        _unitOfWork = unitOfWork;
    }

    public async Task Handle(
        AcceptConsultationCommand command,
        CancellationToken cancellationToken)
    {
        var consultation = await _repository.GetByIdAsync(
            command.ConsultationId,
            cancellationToken);

        if (consultation is null)
            throw new DomainException("Consultation was not found.");

        if (consultation.DoctorId != command.DoctorId)
            throw new DomainException("Doctor is not assigned to this consultation.");

        consultation.Accept();
        consultation.MarkAwaitingPayment();

        await _unitOfWork.SaveChangesAsync(cancellationToken);
    }
}

This is simple, but architecturally important.

The handler does not directly set:

Status = Accepted

It asks the aggregate to perform a business action.

That keeps the rule inside the domain.

Step 4: Payment is confirmed with idempotency

Payments are dangerous because duplicate processing can create real business problems.

A retry should not charge the patient twice.

A repeated callback from a payment provider should not create multiple successful payment records.

So payment confirmation should be idempotent.

public sealed record ConfirmPaymentCommand(
    Guid ConsultationId,
    string PaymentReference
) : ICommand;

A simplified handler could look like this:

public sealed class ConfirmPaymentHandler
    : ICommandHandler<ConfirmPaymentCommand>
{
    private readonly IPaymentRepository _paymentRepository;
    private readonly IConsultationRepository _consultationRepository;
    private readonly IUnitOfWork _unitOfWork;

    public ConfirmPaymentHandler(
        IPaymentRepository paymentRepository,
        IConsultationRepository consultationRepository,
        IUnitOfWork unitOfWork)
    {
        _paymentRepository = paymentRepository;
        _consultationRepository = consultationRepository;
        _unitOfWork = unitOfWork;
    }

    public async Task Handle(
        ConfirmPaymentCommand command,
        CancellationToken cancellationToken)
    {
        var alreadyProcessed = await _paymentRepository
            .ExistsByReferenceAsync(command.PaymentReference, cancellationToken);

        if (alreadyProcessed)
            return;

        var consultation = await _consultationRepository.GetByIdAsync(
            command.ConsultationId,
            cancellationToken);

        if (consultation is null)
            throw new DomainException("Consultation was not found.");

        var payment = Payment.Confirm(
            command.ConsultationId,
            command.PaymentReference);

        await _paymentRepository.AddAsync(payment, cancellationToken);

        consultation.ConfirmPayment();

        await _unitOfWork.SaveChangesAsync(cancellationToken);
    }
}

The exact implementation may vary.

But the principle is important:

Any operation that may be retried must be safe to repeat.

That is especially true for payments, bookings, refunds, and message consumers.

Step 5: Domain events trigger follow-up work

After payment is confirmed, other things may need to happen:

  • Notify the doctor
  • Notify the patient
  • Prepare a video session
  • Update reporting
  • Create reminder jobs

But the payment handler should not directly do all of that.

That would make the payment flow tightly coupled to the rest of the system.

Instead, the domain can raise an event.

public sealed record PaymentConfirmedDomainEvent(
    Guid ConsultationId,
    Guid PatientId,
    Guid DoctorId,
    DateTime OccurredAtUtc
) : IDomainEvent;

Then another handler can react.

public sealed class PaymentConfirmedHandler
{
    private readonly INotificationService _notificationService;

    public PaymentConfirmedHandler(INotificationService notificationService)
    {
        _notificationService = notificationService;
    }

    public async Task Handle(
        PaymentConfirmedDomainEvent domainEvent,
        CancellationToken cancellationToken)
    {
        await _notificationService.SendAsync(
            domainEvent.PatientId,
            "Your payment is confirmed. Your consultation is ready.",
            cancellationToken);

        await _notificationService.SendAsync(
            domainEvent.DoctorId,
            "The patient has completed payment.",
            cancellationToken);
    }
}

This gives us a cleaner model:

  • The payment module owns the payment confirmation
  • The consultation module owns the consultation state
  • The notification module owns communication
  • Events connect the workflow without hard-coupling everything together

This is the real value of domain events.

Step 6: Video consultation starts

Starting or joining a video session is usually synchronous from the user’s perspective.

The patient taps “Join Call.”

The doctor taps “Start Consultation.”

They need an answer immediately.

But the actual video provider should be abstracted.

public interface IVideoSessionProvider
{
    Task<VideoSessionResult> CreateOrGetSessionAsync(
        Guid consultationId,
        CancellationToken cancellationToken);
}

public sealed record VideoSessionResult(
    string RoomUrl,
    string AccessToken,
    DateTime ExpiresAtUtc);

The consultation module should not be tightly coupled to one video vendor.

The application layer can depend on an abstraction, while the infrastructure layer implements the provider-specific details.

That gives us flexibility if we later change providers.

Step 7: Consultation is completed

When a doctor completes a consultation, the system must protect the lifecycle rule.

A consultation should only be completed when it is active.

public sealed record CompleteConsultationCommand(
    Guid ConsultationId,
    Guid DoctorId
) : ICommand;

Handler:

public sealed class CompleteConsultationHandler
    : ICommandHandler<CompleteConsultationCommand>
{
    private readonly IConsultationRepository _repository;
    private readonly IUnitOfWork _unitOfWork;

    public CompleteConsultationHandler(
        IConsultationRepository repository,
        IUnitOfWork unitOfWork)
    {
        _repository = repository;
        _unitOfWork = unitOfWork;
    }

    public async Task Handle(
        CompleteConsultationCommand command,
        CancellationToken cancellationToken)
    {
        var consultation = await _repository.GetByIdAsync(
            command.ConsultationId,
            cancellationToken);

        if (consultation is null)
            throw new DomainException("Consultation was not found.");

        if (consultation.DoctorId != command.DoctorId)
            throw new DomainException("Doctor is not assigned to this consultation.");

        consultation.Complete();

        await _unitOfWork.SaveChangesAsync(cancellationToken);
    }
}

When the consultation is completed, the aggregate can raise:

public sealed record ConsultationCompletedDomainEvent(
    Guid ConsultationId,
    Guid PatientId,
    Guid DoctorId,
    DateTime OccurredAtUtc
) : IDomainEvent;

That event may trigger:

  • prescription workflow
  • reporting update
  • patient follow-up notification
  • AI-generated summary
  • audit log

Not all of that should block the completion request.

Step 8: Prescription PDF is generated

Prescription generation belongs in the Prescription module.

The business data belongs to the module.

The PDF generation implementation belongs to the infrastructure.

public interface IPrescriptionPdfGenerator
{
    Task<byte[]> GenerateAsync(
        Prescription prescription,
        CancellationToken cancellationToken);
}

The application use case might be:

public sealed record GeneratePrescriptionPdfQuery(
    Guid PrescriptionId,
    Guid RequestedByUserId
) : IQuery<byte[]>;

The handler can:

  • authorize access
  • load prescription
  • call PDF generator
  • return the generated file

The important principle is:

The Prescription module owns prescription behavior. The infrastructure only handles the technical rendering.

That keeps business logic out of the PDF library implementation.

Where CQRS helps

In this architecture, I would not force one model to serve both writes and reads.

Writes need to protect business rules.

Reads need to serve the UI efficiently.

For example, the mobile app may need a list of upcoming consultations:

public sealed record UpcomingConsultationDto(
    Guid ConsultationId,
    string DoctorName,
    DateTime ScheduledAtUtc,
    string Status,
    bool CanStartVideoCall,
    bool IsPaymentRequired);

This DTO is not a domain entity.

It is a read model designed for the screen.

That is fine.

The write side should protect the business.

The read side should return useful information efficiently.

That is the practical value of CQRS.

Synchronous vs asynchronous decisions

Not every part of the workflow should behave the same way.

Synchronous operations

These usually need an immediate answer:

  • booking validation
  • doctor acceptance
  • payment confirmation result
  • video session join
  • consultation completion

The user is waiting, and the system needs to respond clearly.

Asynchronous operations

These can happen later:

  • notifications
  • reminder scheduling
  • analytics updates
  • reporting projections
  • AI summary generation
  • email delivery

A useful rule is:

Use synchronous communication for business decisions. Use asynchronous communication for reactions.

This keeps the core workflow clear and prevents non-critical work from slowing down the user-facing request.

What makes this production-ready?

A clean architecture diagram is not enough.

Production systems need to handle failure.

Timeouts

Any downstream call should have a sensible timeout.

For example:

  • payment provider call
  • video provider call
  • notification provider call
  • external profile verification call

The system should not wait forever.

Retries

Retries should be used only for transient failures.

They should be limited and usually use backoff.

Blind retries can make an outage worse.

Idempotency

Idempotency is essential for:

  • payments
  • booking requests
  • external callbacks
  • message consumers
  • retryable commands

Without idempotency, resiliency patterns can accidentally create duplicate business effects.

Outbox pattern

If domain events need to trigger reliable asynchronous work, the outbox pattern is useful.

The idea is simple:

  • Save the business state
  • Save the outgoing event in the same transaction
  • Publish the event later from the outbox

This avoids the classic problem:

The database save succeeded, but the event publish failed.

In production systems, that gap matters.

Observability

A production backend must be understandable when something goes wrong.

That means we need:

  • structured logs
  • metrics
  • traces
  • correlation IDs
  • business event logging

Example:

logger.LogInformation(
    "Consultation {ConsultationId} moved to {Status}. CorrelationId: {CorrelationId}",
    consultationId,
    status,
    correlationId);

Good observability should answer questions like:

  • Why did this booking fail?
  • Was payment confirmed?
  • Did the notification get queued?
  • Did the video session creation fail?
  • Which user performed this action?
  • Which request caused this workflow?

Without observability, production troubleshooting becomes guesswork.

Security and authorization boundaries

Telemedicine systems handle sensitive workflows.

So authorization cannot be an afterthought.

The system must enforce rules such as:

  • A patient can only view their own consultations
  • A doctor can only access consultations assigned to them
  • Prescription data should only be visible to authorized users
  • Payment details should not leak across users
  • Admin access should be explicit and audited

Security belongs at multiple levels:

  • API authorization
  • application use case validation
  • domain rule protection where appropriate
  • audit logging for sensitive actions

A clean module structure helps because each module can own its own security-sensitive decisions.

Trade-offs

No architecture is free.

This design makes deliberate trade-offs.

Why not microservices first?

Because the domain is still easier to evolve inside one deployable unit.

We can keep strong boundaries without paying the full distributed systems cost too early.

Why not event sourcing?

Event sourcing can be powerful, but it adds complexity.

For this example, normal persistence with domain events and an outbox is enough.

Why not one database per module immediately?

Separate databases increase isolation, but also increase operational and consistency complexity.

Inside a modular monolith, we can start with one database and enforce logical module boundaries first.

Why not make everything asynchronous?

Because some decisions need immediate answers.

Booking, payment confirmation, and video session creation are user-facing workflows.

They should not be delayed unnecessarily.

How could this architecture evolve

A modular monolith does not block future growth.

It creates options.

Later, we may extract specific modules when there is a real reason.

For example:

Notifications

This can become a separate service if message volume grows significantly.

Video sessions

This can be isolated if provider complexity, scaling, or real-time requirements increase.

Reporting

This can move to dedicated read models or analytics storage.

Payments

This may require stronger isolation depending on compliance and operational needs.

AI summaries

This can become a background processing pipeline if the workload grows.

The key is that extraction should be based on real pressure, not architectural fashion.

Final thought

A production-ready backend is not created by choosing microservices, adding queues, or using fashionable patterns.

It is created by making good architectural decisions in the right order.

For a telemedicine platform, that means:

  • Define clear business modules
  • Protect important business rules inside aggregates
  • Separate commands and queries
  • Use domain events for meaningful business reactions
  • Keep synchronous flows for immediate decisions
  • Use asynchronous processing for follow-up work
  • Design for resiliency
  • make the system observable
  • evolve only when real pressure appears

The goal of backend architecture is not to make the system look advanced.

The goal is to make it clear, safe, and able to evolve.

That is what turns architecture from diagram-making into practical engineering.

Companion GitHub Repository

If you want to see a practical starter implementation of this architecture, I created a GitHub repository based on the same structure and flow discussed in this article.

It is not intended to be a complete telemedicine product. Instead, it is a backend architecture template that demonstrates how to organize a production-ready modular monolith in .NET.

GitHub: [GitHub Repository]

Backend Architecture Series

This article is part of my Backend Architecture Series.

Recommended related parts:

  • Part 1 — Start With Boundaries, Not Microservices
  • Part 2 — Aggregates Protect Business Rules
  • Part 3 — Domain Events Reduce Coupling Without Losing Business Meaning
  • Part 4 — Commands and Queries Should Not Carry the Same Responsibility
  • Part 5 — Why Modular Monoliths Are Often a Better Starting Point Than Microservices
  • Part 6 — Synchronous vs Asynchronous Communication
  • Part 7 — HTTP, Messaging, and gRPC
  • Part 8 — Resiliency in Backend Systems

In the next main part of the series, I will continue with:

Part 9 — Observability in Backend Systems **Logs, metrics, traces, correlation IDs, and how to make failures understandable in production.**


메타데이터
post_id
00f4203ed034
slug
designing-a-production-ready-backend-in-net-00f4203ed034
url
https://medium.com/@oshadhaj/designing-a-production-ready-backend-in-net-00f4203ed034
canonical_url
https://medium.com/@oshadhaj/designing-a-production-ready-backend-in-net-00f4203ed034
author_url
https://medium.com/@oshadhaj
status
ok
fetched_at
2026-06-24 16:30:55