Clean code in C#: from .NET, CLR and BCL to Clean Architecture

Why can a small application change take an entire day? Start with how .NET works, then explore readable C#, responsibility boundaries and Clean Architecture. Practical examples from daily development and a way to organize existing code without rewriting everything.

The full article is available below. Enable JavaScript to use quizzes and simulations.

Special feature · the developer's workshop

Code you can return to with a clear head.

Monday, 9:12. The coffee is still hot. A message appears in chat: “We're changing the cancellation rules for bookings. That should be a small fix, right?” You open the project. The same rule lives in a controller, a background job and the admin panel. It looks slightly different in each place.

This is when code quality matters: when you need to find a rule, understand its reason and change behavior without accidentally breaking the rest of the system. We will follow that path together, from what .NET actually does through readable C# to boundaries that help an application grow.

01 / Understand the platform02 / Organize the rules03 / Verify the change

Author: ArcCloud Studio · version information as of 8 October 2026. The bicycle workshop and team scenes are educational examples, not descriptions of a client project.

01 / Foundations

What is .NET, and where does C# fit?

On your first project, it is easy to lump C#, .NET, Visual Studio and ASP.NET Core together. In a team, the distinctions quickly become useful. When an application will not start on a server, we need to know whether the runtime is missing, the operating system is wrong or the wrong file was published.

C# is a language; .NET is a platform for building and running applications. We express rules in the language. The platform supplies a runtime, libraries and tools. ASP.NET Core helps build HTTP applications and APIs, among other things. Visual Studio, Rider and VS Code are development tools; changing editor does not change a program's business rules.

Language

C#

You describe behavior: making a booking, canceling an appointment, calculating a price. The compiler checks syntax and types.

Execution

Runtime / CLR

The runtime executes managed code and provides services needed by the running program.

Tools

SDK

You build, run and publish the application. The SDK includes the CLI, compiler and runtime, among other components.

We normally install the SDK on a developer's computer. How the application runs on a server depends on publication: it can use an installed runtime or ship with its own. “I have .NET” can therefore be too imprecise. Establish the SDK version, the project's target framework and the deployment model instead.

Terminal · three questions for the environment

dotnet --list-sdks
dotnet --list-runtimes
dotnet --info

The first command tells you what you can build with. The second lists installed runtimes. The third collects diagnostic information. This is a sensible starting point when a project works for one person but fails for another before reaching the first line of our code.

Documentation: .NET platform components and the SDK and its role.

CLR and BCL: the engine and tools you use every day

CLR, or Common Language Runtime, is the runtime for managed code. On a typical modern .NET server, you will encounter the CoreCLR implementation. The runtime handles code execution, exceptions, the type system and automatic memory management, among other responsibilities. Execution details vary by platform and publication model.

BCL, or Base Class Library, provides fundamental types and operations. When you use string, List<T>, DateTimeOffset or work with files through System.IO, you are using that foundation. Your own Booking class describes your business. The BCL provides building blocks so you do not have to implement text handling, collections and dates yourself.

The practical distinction: “a suitable runtime could not be found” is a different class of problem from an incorrect date comparison in a booking. The first concerns starting the program; the second concerns a rule we wrote. Good diagnosis starts by locating the level where the error occurs.

What happens when you click “Run”?

In a typical JIT model, the C# compiler produces intermediate language, IL, and metadata. At runtime, the JIT compiler translates the required code into processor instructions. This is a simplified path; runtime optimization involves more than one diagram can show.

You writeC#rules and use cases
→
The compiler producesIL + metadataintermediate code and type descriptions
→
Runtime + JITMachine codeprocessor instructions
What about Native AOT?

Native AOT prepares native code during publishing, so execution does not use JIT. This can improve startup time and memory usage. You still need to check library compatibility, reflection use and restrictions on dynamic code loading. The published result targets a specific operating system and architecture. AOT still uses runtime mechanisms, including GC. Base the decision on measurements of your particular application.

The Garbage Collector reclaims memory. We remain responsible for resources.

The GC can reclaim memory for objects that are no longer reachable from the application's roots. If a large dictionary or event handler still holds a reference, an object may remain in memory far longer than intended. Automatic memory management does not remove the need to think about data lifetimes.

Handles and external resources are a separate concern. Close a file when you have finished with it, and use disposable objects according to their contracts. The using and await using constructs help express that intention in code. This is readability too: we can see where a resource is needed and where its lifetime ends.

More: .NET glossary, managed code execution, Native AOT and GC fundamentals.

02 / Versions without guesswork

.NET Framework, .NET Core and .NET: why so many names?

You take over an application and find net48in one project, net8.0in another, while a shared library targets netstandard2.0. These specify different compilation targets. Similar names do not mean every project can be moved to the same server without preparation.

Scroll the table sideways →

FamilyWhat it meansWhat to check in the project
.NET FrameworkThe Windows-specific family. Its latest version is 4.8.1; support depends on the supported operating system.Dependencies on Windows, older ASP.NET, third-party libraries and deployment methods.
.NET CoreThe historical 1.0–3.1 line, developed as a cross-platform system.An upgrade plan. These releases have reached end of support.
.NET 5 and laterThe continuation of .NET Core under a shorter name. The modern platform for multiple operating systems and application types.The target version, support, packages and environment requirements.
.NET StandardA specification of shared APIs for libraries. It is not an application runtime.Who will use the library. .NET Framework does not support .NET Standard 2.1.

There was no “.NET Core 4”. .NET Core 3.1 was followed by .NET 5. ASP.NET Core and Entity Framework Core kept “Core” in their names, so you will still see it in new projects. The C# language version is yet another concept: a project can use a particular language version while targeting a specific framework.

What should you choose today?

As of 8 October 2026, .NET 10 is an LTS release supported until 14 November 2028. .NET 8 LTS and .NET 9 STS are supported until 10 November 2026. For a new application, assess .NET 10 LTS together with library and environment compatibility. For an existing project on 8 or 9, a concrete upgrade plan and post-change behavior checks are becoming the priority.

On that date, .NET 11 is scheduled for November 2026. This article does not treat prerelease software as the default production foundation. If you read it later, consult Microsoft's current table; the dates describe when the article was prepared.

A good migration plan starts with an inventory. List packages, integrations, Windows-specific dependencies, build procedures and business-critical scenarios. Then run tests and plan a return to the previous version. Simply replacing net48 with net10.0 in a project file does not answer those questions.

If you maintain a library shared with a .NET Framework application, netstandard2.0 may still make sense. If the library only serves modern .NET, a modern target lets it use those APIs. Choose based on the library's consumers, not how old the name sounds.

Version sources: the official .NET support policy, .NET Framework support and .NET Standard.

From a developer's desk / Monday, 10:05

“I found three places. Now I have to work out which one tells the truth.”

The team returns to the cancellation-rule change. One method compares hours, another calendar dates, and the third only checks booking status. Understanding the current behavior takes longer than writing the new condition. This is where the conversation about clean code begins.

03 / Readability that makes change easier

What does “clean code” actually mean?

Look at the code through the eyes of the person fixing a bug six months from now. Perhaps they will be a new team member. Perhaps they will be you, after several other projects and a week's holiday. Will they quickly find the rule? Understand where the number 24 came from? Verify the fix without clicking through the whole application manually?

Readability grows from small decisions. The name CanCancel says more than Check. The parameter minimumNotice explains a value that used to be a bare number. A short method helps when it focuses on a coherent task. Breaking a few simple lines into a dozen methods can make them harder to read.

In our example, the bicycle workshop owner decided that customers may cancel no later than 24 hours before an appointment. We must specify the boundaries: exactly 24 hours is allowed; 24 hours minus one clock tick is not. Repeating the cancellation of an already canceled booking should produce an unambiguous result without repeating side effects.

Before · guessing the meaning

if (b.S == 0 &&
    (b.T - DateTime.Now).TotalHours > 24)
{
    b.S = 1;
}
Questions first

Write the rule as a sentence

“A confirmed booking may be canceled at least 24 hours before the appointment.”

Define the statuses, time boundary and retry behavior. Then choose names and types.

This short fragment hides several decisions. What does status zero mean? Why is the comparison stricter than the agreed rule? Is the time local? Where will “now” come from in a test? Reformatting the indentation will resolve none of those questions.

A comment should preserve the reason for a decision

Record why the workshop needs 24 hours: for example, to free the work bay and notify the mechanic. Such a comment helps when the rules are discussed later. A comment saying “changes status to canceled” above Cancel usually just repeats the code.

Avoiding duplication works similarly. The same business rule should have one recognizable source. But two similar fragments supporting different processes should not always be merged. A private customer's appointment and a fleet contract may look similar today but evolve under different rules. A shared abstract “process for everything” often hides that distinction.

Does SOLID still help?

Yes, when you use the principles to ask questions. SRP helps reveal that cancellation policy changes and email-provider changes have different owners. ISP encourages small contracts needed by specific consumers. DIP helps separate a use case from persistence details. LSP reminds us that an implementation must honor its contract. OCP encourages extension points where change really is frequent. Every new layer should still solve an existing problem, and the team should understand its cost.

First the rule. Then a convenient way to use it.

Before adding a database, controller or email sending, we can test the decision itself. The following fragment is a standalone example of a time rule. The downloadable project later in the article extends it with booking state and a use case.

C# · an explicit rule and explicit time

public static bool HasEnoughNotice(
    DateTimeOffset startsAt,
    DateTimeOffset now,
    TimeSpan minimumNotice)
{
    if (minimumNotice < TimeSpan.Zero)
        throw new ArgumentOutOfRangeException(
            nameof(minimumNotice));

    return startsAt - now >= minimumNotice;
}

var allowed = HasEnoughNotice(
    startsAt,
    now,
    TimeSpan.FromHours(24));

We pass time as a value, so the test does not have to wait until tomorrow or change the computer's clock. DateTimeOffset describes an instant together with its UTC offset; comparisons account for that offset. It is not a complete time-zone model, however. If a user books recurring appointments “at 9:00 in Warsaw”, we also need that zone's rules, including daylight-saving changes.

We specified the business rule as 24 hours of actual remaining time. If the policy said “by the end of the previous business day”, the model would have to differ. Public holidays, time zones and working hours would become part of the problem. A good implementation starts with the right business question.

“Now” should not come from the customer's form. The application chooses a trusted time source. In .NET, you can use TimeProviderand control its value in tests. The user supplies a booking identifier; the server determines the time and checks permissions.

Does such a method immediately need its own interface? Not necessarily. A pure function over values is easy to run and understand. Interfaces are most useful at boundaries with dependencies we want to substitute or hide: a data store, clock or external service. Excessive contracts carry their own reading cost.

On controlling time: TimeProvider in Microsoft's documentation.

04 / Responsibility boundaries

C# Clean Architecture in practice

The application grows. A support panel, calendar, notifications and payment integration appear. We want to understand the cancellation rule without reading email configuration. Clean Architecture helps arrange dependencies so technical details do not take control of business logic.

The central rule concerns the direction of dependencies in code: inward, toward rules and use cases. The domain need not know about HTTP controllers or Entity Framework. A use case can define the persistence contract it needs, and infrastructure can implement it.

At the boundary with the outside world

API / interface and Infrastructure

HTTP, database access, email and external services. Adapters fit the outside world to the application's needs.

Use case

Application

“Cancel booking”: retrieve, ask the domain, attempt to save and return a result.

Business rule

Domain

Booking status, appointment time and required notice. Independent of HTTP and a particular database.

The diagram shows code dependencies. At runtime, calls may flow from Application through an interface to an Infrastructure adapter.

That last detail often causes confusion. “But the application has to call the database!” Yes, but it does not need to know the specific database-handling class. An interface defines a contract. An adapter fulfills it. Startup code, sometimes called the composition root, connects the pieces through dependency injection.

In a small project, we can start expressing boundaries with modules and folders. Separate projects later help enforce reference rules at compile time. What matters is that the Domain folder does not quietly import the database context and that Application does not become another drawer for everything.

Example structure · project dependencies

Domain          → .NET base libraries
Application     → Domain
Infrastructure  → Application, Domain
Demo / API      → Application, Infrastructure
                  + dependency registration

Such an application can remain one deployed process. A modular monolith often provides sufficient boundary control without adding network communication between services. Extract a microservice only when there are concrete reasons: team ownership, independent deployment or different operational requirements.

Dependency direction and example structures: web application architectures in Microsoft's documentation.

05 / One use case

Follow a booking cancellation from start to finish

The customer selects a booking and clicks “Cancel”. In a complete application, the entry layer first confirms their identity and right to that specific booking. A valid identifier alone is not permission. It then passes the request to the use case that coordinates the operation.

  1. Input.The API reads the identifier, validates the request and checks authorization for the resource.
  2. Read.Application retrieves the booking through a repository contract. A missing record is an explicit result.
  3. Decision.The domain checks state and time. This is where the 24-hour rule lives.
  4. Write.The adapter attempts to persist the new state with version checking. A conflict is not a silent success.
  5. Response.The API maps the result to the agreed HTTP contract. The interface tells the user what happened.

This is a good moment to name the operation's outcomes. Cancelled, AlreadyCancelled, TooLate, NotFound and Conflict tell us what happened. An expected business refusal need not be an exception. An unexpected database connection failure needs separate handling and must not pretend that the booking simply was not found.

See a domain fragment from the downloadable project

Domain/Booking.cs · a method on the Booking class

public BookingCancellation Cancel(
    DateTimeOffset now,
    CancellationPolicy policy)
{
    if (Status == BookingStatus.Cancelled)
        return new(this,
            CancellationDecision.AlreadyCancelled);

    if (StartsAt <= now)
        return new(this,
            CancellationDecision.VisitStarted);

    if (StartsAt - now < policy.MinimumNotice)
        return new(this,
            CancellationDecision.TooLate);

    return new(
        this with { Status = BookingStatus.Cancelled },
        CancellationDecision.Cancelled);
}

In this example, Booking is a record. A successful decision creates a new value with canceled status. Other decisions retain the existing object. Full types, constructors and contracts are in the archive.

What does a repository contract give us?

Our workshop's use case needs two capabilities: finding a booking and attempting to save its new state with an expected version. That small contract is easy to read. We are not building a universal repository for every data type with dozens of methods nobody needs here.

A real application may choose a different trade-off, such as using EF Core directly within a particular module. Accept that dependency and testing approach deliberately. Clean Architecture does not require buying a particular library, adding a mediator or creating an interface for every class. Its purpose is to help the team maintain understandable boundaries.

What if two people click at the same time?

A read may be current only briefly. Someone else can change the booking between retrieval and saving. Our example therefore carries the read record's version into the write attempt. The adapter should recognize that the expected version no longer applies. Application code must know whether to reread or return a conflict.

In a production adapter, the persistence mechanism must check the version condition atomically. A separate “does the version match?” query does not protect a subsequent update. The in-memory example demonstrates the contract but does not replace transaction and concurrency tests against a real database engine.

See: handling concurrency conflicts in EF Core.

From a developer's desk / Friday, 16:40

The booking is canceled. The email never arrived. What should the customer see?

The email provider stopped responding after the database write. The button is still waiting. The customer refreshes the page and tries again. This is when transaction boundaries and retry handling matter more than an elegant diagram.

Readable code still has to meet the real world

Saving a booking and sending a message through an external provider usually do not share one transaction. If we treat them as inseparable without a failure-handling plan, we may display an error even though the change was successfully saved. The user then does not know whether to repeat the request.

One solution is an outbox: in the same transaction as the state change, we record that a notification needs sending. A separate process attempts delivery and records progress. Questions remain about retries, duplicates, operation identifiers, attempt limits and detecting stuck jobs. Naming a table “Outbox” does not guarantee exactly-once message delivery.

In this workshop, we deliberately stop before email integration. We want to understand one boundary well and then expand the scope explicitly. The download does not include production login, booking ownership checks, a SQL database or payments. Those elements must be designed and verified in the context of a particular service.

Async, exceptions and logs affect readability too

For input/output operations, consistently propagate CancellationToken and use asynchronous APIs. The word async does not make computation faster by itself or automatically start a new thread. It describes how code cooperates while waiting for an operation to finish.

Error handling needs similar discipline. An empty catch may silence an alarm while leaving the user in an uncertain state. A log should help reconstruct the flow: operation identifier, stage and outcome. It does not need a full password, token or entire form contents. Design diagnosability alongside the use case.

Technical background: partial failures and application resilience.

06 / Verify before calling a change finished

Tests that answer the team's questions

The easiest test confirms the happy path. In our workshop, next week's booking can be canceled. That is a useful start, but most misunderstandings hide at rule boundaries and in repeated operations.

Scroll the table sideways →

SituationExpected behaviorWhat we verify
Exactly 24 hours before the appointmentCancellation allowedThe boundary specified by the policy
24 hours minus one tickRefusal: too lateExact comparison without rounding
The same instant with a different UTC offsetThe same resultComparing instants, not clock appearances
Already canceled bookingAn explicit retry outcomeNo repeated state change
Record changed after readingA recognized conflictThe version-checking contract
Successful cancellation followed by another readCanceled state is visibleActual persistence in the selected adapter

A domain test runs without a server. A use-case test checks collaboration between the rule, clock and persistence contract. An adapter integration test should establish whether our queries, transactions and constraints work on the selected database engine. Finally, a user-journey test checks whether the screen can show success, refusal and failure.

Do not confuse these forms of evidence. A green test on an in-memory collection does not prove a SQL query correct. A different database engine may handle comparisons, types or transactions differently. Even 100% line coverage does not, by itself, tell you whether the most important risks were tested.

Downloadable workshop · C#

Bicycle workshop: a small project following one decision end to end

The archive contains Domain, Application, an in-memory adapter, a demonstration program and verification scenarios. No additional NuGet packages. Follow the dependencies, change the rules and see which expectations no longer hold.

Download the C# project and instructions · ZIP ↓

Project target framework: .NET 10. The README describes requirements, execution, completed verification and the demonstration's limitations.

An exercise for a quiet half hour

The owner wants to reduce the notice period from 24 to 12 hours. Before changing code, write examples for exactly 12 hours and the instant just below that boundary. Then update the policy and run the scenarios. Notice whether you have to edit the controller, adapter and model simultaneously. If so, find where the rule escaped its responsibility boundary.

The second part is harder: the new rules apply only to bookings created after a particular date. A global change to one value could now violate previously accepted terms. Perhaps a booking should remember its policy version. This shows why we should ask what a change means before celebrating how simple it looks in code.

On choosing evidence: a testing strategy for applications using EF Core.

Time to practice

Three decisions at a real desk

Choose an answer and read the explanation. You can try again. This exercise runs locally in the browser; it is neither a skills assessment nor a certificate.

1. Where would you put the 24-hour rule?

Only in the Angular button and form.

In the domain, called by the use case.

Choose an answer to see the explanation.

2. You want to detect a transaction defect in the SQL adapter.

Add an integration test against the correct database engine.

Replace the database with List<Booking> and call it done.

Choose an answer to see the explanation.

3. A small application has two simple forms and one team.

Immediately split it into several microservices.

Start with simple modules and assess where boundaries are needed.

Choose an answer to see the explanation.

Decisions practiced: 0/3

Start again

Answer three questions. Each decision includes an explanation.

Answers and explanations as text

1. The rule belongs in the domain, called by the use case. Form validation improves usability but does not protect the server. 2. Transaction behavior requires testing the adapter against the correct engine in a separate test database. 3. Simple modules may be enough; expand the boundaries as the need grows. These answers are also available without interactive buttons.

From a developer's desk / first Wednesday on a new team

“I found CancelBooking. I can see the rule and its boundary test.”

The new person does not yet know the project's history. Even so, they can identify where to change it and explain the consequences. This is a good signal of quality: knowledge about the system is available in code, tests and brief documentation rather than only in one person's memory.

07 / A project already earning its keep

How do you start refactoring without rewriting everything?

In an existing application, carefully organizing one frequently changed process often delivers the most value. Choose an area with recurring defects, difficult tests or simple requests that require edits across many modules. Instead of disturbing the whole system, map one operation from input through the rule to persistence and response.

First record what the application does today. Characterization tests capture current behavior, even when documentation is incomplete. Not every existing detail is intentional, so discuss deviations from expectations. Refactoring organizes structure while preserving agreed behavior. Separate business-rule changes so the reviewer knows exactly what they are approving.

  1. Understand the process.Inputs, data, rules, integrations and side effects. Name the owner of the business decision.
  2. Protect existing behavior.Add tests for important scenarios and a way to reproduce the reported problem.
  3. Extract one responsibility.Move a rule or adapter in a small step. Keep the application working after every change.
  4. Check the boundaries.Refusal, missing data, conflicts, retries and dependency failures. Match tests to risk.
  5. Deploy and observe.Keep a way back and the information needed to assess behavior after deployment.

Code review as a discussion about the next change

During review, ask: “What happens if the write fails?”, “Can this method be called twice?” and “Which test verifies this boundary?” These questions help the author sharpen the solution. A discussion focused only on bracket placement is less useful; tools can enforce mechanical formatting rules.

A good change description explains the problem, new behavior and verification in a few sentences. For a difficult decision, a brief architectural note preserves context: what we chose, why and when to revisit it. The next person then does not have to reconstruct weeks of conversation from chat history.

A short checklist for the next pull request
  • Do names describe concepts used by the business?
  • Does the rule have one recognizable source?
  • Can time and dependencies be controlled in tests?
  • Do refusal, technical failure and conflict have clear outcomes?
  • Did we check access to the resource, not just login status?
  • Is it clear how to deploy and roll back safely?
  • Have we explained the limits of our tests and the remaining risks?

When is a simpler solution enough?

Not every application needs an elaborate domain model. A panel with a few simple CRUD operations can remain readable with only a small number of layers. Well-named use cases, consistent validation and critical-path tests can be a sufficient starting point.

Stronger boundaries become valuable as rules, integrations, entry points or teams multiply. The cost of determining what can safely change may then exceed the cost of additional contracts. Architecture should respond to that specific situation.

Leave room for gradual growth too. You do not need to predict every future payment provider. You can separate your own billing process from today's SDK details, however. When providers change, you know where to start the analysis. Contract, data and behavioral differences still need checking; an interface alone does not remove them.

A question worth keeping

If one rule changes tomorrow, will the team know where to update it and how to confirm everything else still works?

From reading to your own project

Does every change in your system trigger another round of fixes?

Show us one concrete process: login, booking, ordering, payment or integration. At ArcCloud Studio, we work with C#, .NET, Angular, AWS and Azure. We can examine where responsibilities mix, which problems affect users and where repairs should begin.

01 / Diagnosis

Agree what to examine

Business goals, symptoms, available materials and an agreed scope. We establish access and confidentiality before sensitive information is shared.

02 / Plan

Describe concrete actions

Problems, impact, priorities, proposed fixes and acceptance criteria. Implementation scope, price and schedule are agreed separately.

03 / Implementation

Verify the effect of the change

After approval, we can implement improvements, run the agreed tests and provide the documentation the team needs.

Architecture and code reviews require access to appropriate materials and a jointly agreed scope. An automated measurement of a public URL does not replace that work.

For your first message, a problem description, the technologies used and the desired outcome are enough. Do not send passwords or access keys through a public form.

If you want to go a step further

Return to the diagram and trace one process in your own project. Then read our article on architectural decisions and documenting their reasons or the workshop on queues, retries and resilient integrations.

Microsoft sources are linked beside the relevant chapters. We created the stories, workshop structure and demonstration code for this article. The cover is a conceptual illustration created with AI assistance; it does not depict a client's system.

If you can now explain the difference between a language, runtime and libraries, identify who owns a rule and choose a test that addresses a risk, you have a good starting point for your next change. Begin with one process. See what becomes easier to understand.