C# File Structure and Best Practices

C# File Structure and Best Practices

Writing clean C# code is not only about choosing the right algorithms or learning the latest language features. A big part of writing professional software is something much simpler and much more important: organizing your files and folders in a way that helps you and your team move quickly without creating chaos.

At the beginning, many C# projects start the same way. Everything works. The app compiles. The features are coming in. Life feels good.

Then the project grows.

A few controllers become ten controllers. A few services become dozens. Helpers start appearing everywhere. One folder contains business logic, UI logic, data access logic, and a few random classes that nobody wanted to move because “it still works.” Before long, the project becomes harder to read than to write.

That is usually the moment when file structure stops being an aesthetic choice and becomes a productivity issue.

Good structure helps you find code faster, test code more easily, onboard new developers faster, and reduce mistakes. It also makes refactoring less painful. In other words, it saves time now and later.

In this article, we will go deep into C# file structure and best practices. You will see how to organize a project, how to name files and folders, how to separate concerns, how to keep namespaces consistent, and how to avoid common mistakes. You will also see practical code examples you can use as a guide in real projects.


Why file structure matters in C#

A C# project is not just a collection of classes. It is a system of responsibilities.

When the structure is clean, the code tells a story:

  • where the app starts,

  • where business rules live,

  • where data access happens,

  • where models are defined,

  • where tests are stored,

  • and where to make changes when a feature needs to evolve.

When the structure is poor, every change turns into a search mission.

A developer should not need to inspect fifteen files just to understand where a single piece of logic belongs. That is exactly what good structure prevents.

A strong file structure gives you:

  • readability,

  • maintainability,

  • testability,

  • scalability,

  • predictable navigation,

  • less confusion during team collaboration.

A messy project often works fine until the moment you need to change it. Then the cost appears.


The core idea: one file, one responsibility

A useful rule of thumb is to keep each file focused on one main responsibility.

That does not mean every class must contain only one tiny method. It means the file should represent a clear concept.

For example:

  • CustomerService.cs should contain customer-related service logic.

  • CustomerController.cs should contain HTTP request handling for customers.

  • CustomerDto.cs should represent data sent to or from the API.

  • CustomerRepository.cs should manage customer data access.

When a file starts doing too many things, it becomes a dumping ground. That usually happens when people keep adding “just one more method” or “just one helper” without asking whether the file still has a clear purpose.

A good file structure makes responsibility visible.


A simple and clean C# project structure

There is no single structure that fits every project, but many applications benefit from a layered layout.

Here is a practical example:

MyApp/
│
├── Controllers/
│   └── ProductsController.cs
│
├── Services/
│   ├── IProductService.cs
│   └── ProductService.cs
│
├── Repositories/
│   ├── IProductRepository.cs
│   └── ProductRepository.cs
│
├── Models/
│   └── Product.cs
│
├── DTOs/
│   ├── ProductCreateDto.cs
│   ├── ProductUpdateDto.cs
│   └── ProductResponseDto.cs
│
├── Mappings/
│   └── ProductProfile.cs
│
├── Data/
│   └── AppDbContext.cs
│
├── Middleware/
│   └── ExceptionMiddleware.cs
│
├── Helpers/
│   └── DateTimeHelper.cs
│
├── Tests/
│   ├── ProductServiceTests.cs
│   └── ProductControllerTests.cs
│
└── Program.cs

This structure is easy to understand because the folders describe purpose, not implementation noise.

A developer who opens the project can immediately see where to look for controllers, business logic, repositories, models, and tests.


Keep folders meaningful, not decorative

A folder should answer a real question.

Bad folder names are vague and unhelpful:

  • Stuff

  • Common

  • Misc

  • Utils

  • Temp

  • Old

  • New

  • Helpers2

These names usually mean the project is growing without clear rules.

Better folder names describe the responsibility clearly:

  • Controllers

  • Services

  • Repositories

  • DTOs

  • Validators

  • Infrastructure

  • Domain

  • Application

  • Tests

When you create a folder, ask:

“What kind of code belongs here, and why?”

If the answer is not clear, the folder probably should not exist yet.


Use namespaces that match folders

In C#, namespaces are more than decoration. They help organize code logically.

If your folder structure says:

Services/
    ProductService.cs

then your namespace should reflect that structure:

namespace MyApp.Services
{
    public class ProductService
    {
    }
}

This makes code easier to understand and navigate.

A good rule is to keep namespaces aligned with the project structure, especially in medium and large applications.

For example:

namespace MyApp.Controllers
{
    public class ProductsController : ControllerBase
    {
    }
}
namespace MyApp.DTOs
{
    public class ProductCreateDto
    {
        public string Name { get; set; } = string.Empty;
        public decimal Price { get; set; }
    }
}

This alignment makes solution browsing more intuitive.


Prefer small classes over huge classes

One of the most common structure problems in C# projects is the “God class.”

A God class does everything:

  • validation,

  • formatting,

  • database access,

  • business rules,

  • logging,

  • email sending,

  • caching,

  • and maybe even a bit of UI logic for no good reason.

These classes are hard to test and hard to maintain.

Instead, split responsibilities into smaller classes.

For example, this is not ideal:

public class OrderManager
{
    public void CreateOrder()
    {
        // validate
        // calculate
        // save to database
        // send email
        // log result
    }
}

A better approach:

public class OrderService
{
    private readonly IOrderRepository _orderRepository;
    private readonly IEmailService _emailService;
    private readonly IOrderValidator _orderValidator;

    public OrderService(
        IOrderRepository orderRepository,
        IEmailService emailService,
        IOrderValidator orderValidator)
    {
        _orderRepository = orderRepository;
        _emailService = emailService;
        _orderValidator = orderValidator;
    }

    public async Task CreateOrderAsync(Order order)
    {
        _orderValidator.Validate(order);
        await _orderRepository.AddAsync(order);
        await _emailService.SendOrderConfirmationAsync(order);
    }
}

Now each dependency has a clear role.

That is easier to test, easier to change, and easier to reuse.


Follow naming conventions consistently

A clean structure is not only about folders. It is also about names.

C# has well-established naming conventions, and following them makes code feel native and readable.

Common C# naming rules

  • Classes, interfaces, methods, properties: PascalCase

  • Private fields: _camelCase

  • Local variables and parameters: camelCase

  • Interfaces: usually start with I

  • Files: usually match the public class name

Examples:

public interface IProductService
{
    Task<ProductDto> GetByIdAsync(int id);
}
public class ProductService : IProductService
{
    private readonly IProductRepository _productRepository;

    public ProductService(IProductRepository productRepository)
    {
        _productRepository = productRepository;
    }

    public Task<ProductDto> GetByIdAsync(int id)
    {
        // ...
    }
}

Good naming reduces the mental effort required to understand code.

If someone sees ProductService, they know exactly what to expect. If they see ManagerHelperV2, they know they are in trouble.


Match file names with class names

In C#, one public class per file is a very strong habit.

For example:

  • ProductService.cs contains ProductService

  • OrderController.cs contains OrderController

  • EmailSender.cs contains EmailSender

This makes search and navigation much easier.

It also reduces confusion when working in IDEs like Visual Studio or VS Code.

A file should not be named Services.cs if it contains one class called CustomerService. That creates friction later when someone tries to find or rename the class.

There are exceptions, but for most projects, matching file name and class name is the cleanest choice.


Keep one public type per file

This is one of the most useful file structure habits in C#.

Instead of putting several public classes in one file, separate them.

Bad example:

public class ProductService
{
}

public class OrderService
{
}

public class CustomerService
{
}

Better:

ProductService.cs

public class ProductService
{
}

OrderService.cs

public class OrderService
{
}

CustomerService.cs

public class CustomerService
{
}

Why does this matter?

Because each class becomes easier to locate, review, test, and change. It also prevents files from turning into crowded storage rooms.


Use interfaces where they add value

In many C# projects, interfaces are placed beside their implementations.

For example:

Services/
    IProductService.cs
    ProductService.cs

This is a clean pattern because it keeps related concepts close together.

Example

IProductService.cs

namespace MyApp.Services
{
    public interface IProductService
    {
        Task<ProductResponseDto?> GetByIdAsync(int id);
        Task<IEnumerable<ProductResponseDto>> GetAllAsync();
        Task<ProductResponseDto> CreateAsync(ProductCreateDto dto);
    }
}

ProductService.cs

namespace MyApp.Services
{
    public class ProductService : IProductService
    {
        public Task<ProductResponseDto?> GetByIdAsync(int id)
        {
            throw new NotImplementedException();
        }

        public Task<IEnumerable<ProductResponseDto>> GetAllAsync()
        {
            throw new NotImplementedException();
        }

        public Task<ProductResponseDto> CreateAsync(ProductCreateDto dto)
        {
            throw new NotImplementedException();
        }
    }
}

This structure makes dependency injection straightforward and improves testability.

That said, do not create interfaces just because you can. Use them when there is a real benefit, such as abstraction, test isolation, or multiple implementations.


Keep controllers thin

In ASP.NET Core, controllers should usually handle HTTP concerns, not business logic.

A controller should:

  • receive requests,

  • validate basic input,

  • call the service layer,

  • return responses.

A controller should not contain huge chunks of business logic.

Good controller example

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IProductService _productService;

    public ProductsController(IProductService productService)
    {
        _productService = productService;
    }

    [HttpGet("{id}")]
    public async Task<ActionResult<ProductResponseDto>> GetById(int id)
    {
        var product = await _productService.GetByIdAsync(id);

        if (product is null)
            return NotFound();

        return Ok(product);
    }

    [HttpPost]
    public async Task<ActionResult<ProductResponseDto>> Create(ProductCreateDto dto)
    {
        var created = await _productService.CreateAsync(dto);
        return CreatedAtAction(nameof(GetById), new { id = created.Id }, created);
    }
}

This controller is focused and readable.

Bad controller example

[HttpPost]
public async Task<IActionResult> Create(ProductCreateDto dto)
{
    if (string.IsNullOrWhiteSpace(dto.Name))
        return BadRequest();

    if (dto.Price < 0)
        return BadRequest();

    var product = new Product
    {
        Name = dto.Name.Trim(),
        Price = dto.Price
    };

    _db.Products.Add(product);
    await _db.SaveChangesAsync();

    await _emailSender.SendAsync("admin@example.com", "New product created");

    return Ok(product);
}

This works, but it mixes concerns. Validation, mapping, persistence, and notification should not all live in the controller.


Put business logic in the service layer

The service layer is often the heart of a clean C# application.

It is where rules live.

For example, suppose you want to create a product only if the price is greater than zero and the name is unique.

That logic should probably live in a service, not in the controller or repository.

Example

public class ProductService : IProductService
{
    private readonly IProductRepository _productRepository;

    public ProductService(IProductRepository productRepository)
    {
        _productRepository = productRepository;
    }

    public async Task<ProductResponseDto> CreateAsync(ProductCreateDto dto)
    {
        if (string.IsNullOrWhiteSpace(dto.Name))
            throw new ArgumentException("Product name is required.");

        if (dto.Price <= 0)
            throw new ArgumentException("Product price must be greater than zero.");

        var existingProduct = await _productRepository.GetByNameAsync(dto.Name);
        if (existingProduct is not null)
            throw new InvalidOperationException("A product with this name already exists.");

        var product = new Product
        {
            Name = dto.Name.Trim(),
            Price = dto.Price
        };

        await _productRepository.AddAsync(product);

        return new ProductResponseDto
        {
            Id = product.Id,
            Name = product.Name,
            Price = product.Price
        };
    }

    public async Task<ProductResponseDto?> GetByIdAsync(int id)
    {
        var product = await _productRepository.GetByIdAsync(id);
        if (product is null)
            return null;

        return new ProductResponseDto
        {
            Id = product.Id,
            Name = product.Name,
            Price = product.Price
        };
    }

    public async Task<IEnumerable<ProductResponseDto>> GetAllAsync()
    {
        var products = await _productRepository.GetAllAsync();

        return products.Select(p => new ProductResponseDto
        {
            Id = p.Id,
            Name = p.Name,
            Price = p.Price
        });
    }
}

This structure keeps important rules in one place.

When business logic changes, you know exactly where to look.


Separate domain models from DTOs

This is one of the most important best practices in C# file structure.

A domain model is not always the same thing as a DTO.

  • A domain model represents the core business entity.

  • A DTO represents data moving into or out of a layer, usually the API.

Keeping them separate protects your application from becoming tightly coupled.

Domain model

namespace MyApp.Models
{
    public class Product
    {
        public int Id { get; set; }
        public string Name { get; set; } = string.Empty;
        public decimal Price { get; set; }
        public DateTime CreatedAt { get; set; }
    }
}

DTOs

namespace MyApp.DTOs
{
    public class ProductCreateDto
    {
        public string Name { get; set; } = string.Empty;
        public decimal Price { get; set; }
    }
}
namespace MyApp.DTOs
{
    public class ProductResponseDto
    {
        public int Id { get; set; }
        public string Name { get; set; } = string.Empty;
        public decimal Price { get; set; }
    }
}

Why separate them?

Because your API contract should not be forced to match your database structure exactly. This gives you flexibility and reduces accidental exposure of internal data.

For example, you may not want to expose internal fields such as CreatedAt, IsDeleted, or InternalCode in your public API.


Use a clear data folder or infrastructure layer

In many applications, data access should live in its own area.

For example:

Data/
    AppDbContext.cs
Repositories/
    IProductRepository.cs
    ProductRepository.cs

Example DbContext

public class AppDbContext : DbContext
{
    public DbSet<Product> Products => Set<Product>();

    public AppDbContext(DbContextOptions<AppDbContext> options)
        : base(options)
    {
    }
}

Example repository

public class ProductRepository : IProductRepository
{
    private readonly AppDbContext _context;

    public ProductRepository(AppDbContext context)
    {
        _context = context;
    }

    public async Task<Product?> GetByIdAsync(int id)
    {
        return await _context.Products.FindAsync(id);
    }

    public async Task<Product?> GetByNameAsync(string name)
    {
        return await _context.Products
            .FirstOrDefaultAsync(p => p.Name == name);
    }

    public async Task<IEnumerable<Product>> GetAllAsync()
    {
        return await _context.Products.ToListAsync();
    }

    public async Task AddAsync(Product product)
    {
        _context.Products.Add(product);
        await _context.SaveChangesAsync();
    }
}

A repository is useful when it helps isolate data access details. It can make testing easier and keep your service layer cleaner.


Keep helper classes small and honest

Many projects have a Helpers folder. That can be fine, but it is also one of the easiest places for bad structure to hide.

A helper should do something small and specific.

For example:

public static class DateTimeHelper
{
    public static string ToFriendlyDate(DateTime dateTime)
    {
        return dateTime.ToString("yyyy-MM-dd");
    }
}

This is acceptable if the helper is truly a general utility.

But if your Helpers folder starts containing unrelated code like email formatting, file cleanup, and discount calculations, that is a sign the folder is becoming a junk drawer.

A better approach is to create focused classes instead of a giant helper bucket.

For example:

  • DateTimeFormatter

  • PasswordHasher

  • SlugGenerator

  • FilePathBuilder

These names are clearer than a generic Helper suffix everywhere.


Prefer feature-based structure for bigger projects

For large applications, folder-by-layer structure can become too broad.

At that point, a feature-based structure may be better.

Instead of organizing by technical type only, organize by business feature.

Example

MyApp/
├── Features/
│   ├── Products/
│   │   ├── Create/
│   │   │   ├── CreateProductRequest.cs
│   │   │   ├── CreateProductHandler.cs
│   │   │   └── CreateProductValidator.cs
│   │   ├── GetById/
│   │   │   ├── GetProductByIdQuery.cs
│   │   │   └── GetProductByIdHandler.cs
│   │   └── ProductDto.cs
│   └── Orders/
│       ├── Create/
│       └── GetById/

This approach is very popular in CQRS-style applications and large systems because it groups related files by behavior.

A feature-based structure helps developers work within a single business area without jumping across many broad folders.


Organize tests the same way you organize code

Tests should not be an afterthought. A clean project structure includes clean test structure.

A common pattern is to mirror your main code structure in the test project.

Example:

MyApp.Tests/
├── Services/
│   └── ProductServiceTests.cs
├── Controllers/
│   └── ProductsControllerTests.cs
├── Repositories/
│   └── ProductRepositoryTests.cs

Example test

public class ProductServiceTests
{
    [Fact]
    public async Task CreateAsync_ShouldThrow_WhenNameIsEmpty()
    {
        var mockRepo = new Mock<IProductRepository>();
        var service = new ProductService(mockRepo.Object);

        var dto = new ProductCreateDto
        {
            Name = "",
            Price = 10
        };

        await Assert.ThrowsAsync<ArgumentException>(() => service.CreateAsync(dto));
    }
}

This structure helps you quickly find the test that corresponds to the class you are changing.

Tests become easier to navigate when the structure reflects the application.


Avoid dumping all utility code into a single folder

At some point, almost every project has a folder like this:

Utils/

It starts with one or two useful classes.

Then more code gets added.

Then more.

Soon the folder contains code that has nothing in common except the fact that nobody knew where else to put it.

A better rule is to ask whether a class belongs to a real concept, a real feature, or a real service.

For example:

  • FileNameSanitizer

  • CurrencyFormatter

  • PhoneNumberParser

  • TokenGenerator

These are not just “utils.” They are specific responsibilities.

A project with explicit names is easier to understand than a project with mystery boxes.


Keep configuration organized

Configuration files and app settings should also be structured with intent.

In ASP.NET Core, appsettings.json often contains application settings, connection strings, and logging values.

Example:

{
  "ConnectionStrings": {
    "DefaultConnection": "Server=.;Database=MyAppDb;Trusted_Connection=True;TrustServerCertificate=True"
  },
  "EmailSettings": {
    "SmtpServer": "smtp.example.com",
    "Port": 587,
    "Username": "noreply@example.com"
  }
}

Then create typed settings classes:

public class EmailSettings
{
    public string SmtpServer { get; set; } = string.Empty;
    public int Port { get; set; }
    public string Username { get; set; } = string.Empty;
}

And register them in Program.cs:

builder.Services.Configure<EmailSettings>(
    builder.Configuration.GetSection("EmailSettings"));

This is cleaner than scattering configuration values across code.

It also reduces the chance of typos and hardcoded magic strings everywhere.


Keep Program.cs clean

In modern C# projects, especially ASP.NET Core projects, Program.cs is often the application entry point.

It should stay readable.

A very large Program.cs becomes difficult to manage, especially when service registration and middleware setup grow.

Instead of stuffing everything into one place, move repeated setup into extension methods.

Example

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddApplicationServices(this IServiceCollection services)
    {
        services.AddScoped<IProductService, ProductService>();
        services.AddScoped<IProductRepository, ProductRepository>();
        return services;
    }
}

Then in Program.cs:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddApplicationServices();

var app = builder.Build();

app.MapControllers();
app.Run();

Now the startup file stays readable and the registration logic is easier to reuse.


Use partial classes sparingly

Partial classes can be useful in generated code or special scenarios, but they should not become a default habit for splitting logic.

A class split into several partial files can become hard to trace if used carelessly.

A good question to ask is:

“Does this class really belong together, or am I hiding complexity by splitting it into pieces?”

Most of the time, the answer should be to refactor into separate classes instead of using partial classes.

Partial classes are not a structure strategy. They are a tool for specific situations.


Make validation its own responsibility

Validation is another area where structure matters.

Do not let validation logic get scattered randomly across controllers and services.

There are multiple ways to organize validation in C#:

  • Data annotations,

  • FluentValidation,

  • custom validator classes.

Example with validation class

public interface IProductValidator
{
    void Validate(ProductCreateDto dto);
}
public class ProductValidator : IProductValidator
{
    public void Validate(ProductCreateDto dto)
    {
        if (string.IsNullOrWhiteSpace(dto.Name))
            throw new ArgumentException("Name is required.");

        if (dto.Price <= 0)
            throw new ArgumentException("Price must be greater than zero.");
    }
}

Then inject it into the service:

public class ProductService : IProductService
{
    private readonly IProductRepository _productRepository;
    private readonly IProductValidator _productValidator;

    public ProductService(
        IProductRepository productRepository,
        IProductValidator productValidator)
    {
        _productRepository = productRepository;
        _productValidator = productValidator;
    }

    public async Task<ProductResponseDto> CreateAsync(ProductCreateDto dto)
    {
        _productValidator.Validate(dto);

        var product = new Product
        {
            Name = dto.Name.Trim(),
            Price = dto.Price
        };

        await _productRepository.AddAsync(product);

        return new ProductResponseDto
        {
            Id = product.Id,
            Name = product.Name,
            Price = product.Price
        };
    }
}

This keeps validation discoverable and reusable.


Put mapping in one place

Many applications need to convert between entities and DTOs.

This mapping should not be repeated everywhere.

You can organize it using:

  • manual mapping methods,

  • extension methods,

  • mapping profiles.

Example mapping extension

public static class ProductMappingExtensions
{
    public static ProductResponseDto ToResponseDto(this Product product)
    {
        return new ProductResponseDto
        {
            Id = product.Id,
            Name = product.Name,
            Price = product.Price
        };
    }

    public static Product ToEntity(this ProductCreateDto dto)
    {
        return new Product
        {
            Name = dto.Name.Trim(),
            Price = dto.Price
        };
    }
}

Then your service becomes cleaner:

public async Task<ProductResponseDto> CreateAsync(ProductCreateDto dto)
{
    var product = dto.ToEntity();
    await _productRepository.AddAsync(product);
    return product.ToResponseDto();
}

Mapping logic in one place reduces duplication and mistakes.


Avoid cyclic dependencies between folders

A common structure mistake is letting folders depend on each other in a tangled way.

For example:

  • Controllers depend on Services

  • Services depend on Repositories

  • Repositories depend on Data

  • Data depends on Services again

That kind of circular design makes systems fragile.

A better dependency flow is usually:

  • Presentation layer depends on Application layer

  • Application layer depends on Domain layer

  • Infrastructure layer implements abstractions from Application or Domain

A clean dependency direction makes the code easier to reason about.

You do not need a fancy architecture to follow this principle. Even simple apps benefit from it.


Put extension methods in their own files

Extension methods can be very helpful, but they should be organized carefully.

For example:

public static class StringExtensions
{
    public static string ToSlug(this string text)
    {
        return text.Trim().ToLowerInvariant().Replace(" ", "-");
    }
}

This belongs in a file like StringExtensions.cs under a folder such as Extensions/.

That keeps utility code discoverable and avoids burying it inside unrelated classes.

A common structure:

Extensions/
    StringExtensions.cs
    DateTimeExtensions.cs
    EnumerableExtensions.cs

This is much better than placing extensions in random service files.


Keep asynchronous code consistent

As your project grows, async code can get messy if naming and structure are inconsistent.

A few good habits help a lot:

  • Use Async suffix for asynchronous methods.

  • Keep async methods truly async.

  • Do not mix sync and async versions casually.

  • Avoid blocking on async calls.

Example

public interface IProductRepository
{
    Task<Product?> GetByIdAsync(int id);
    Task<IEnumerable<Product>> GetAllAsync();
    Task AddAsync(Product product);
}

And usage:

public async Task<ProductResponseDto?> GetByIdAsync(int id)
{
    var product = await _productRepository.GetByIdAsync(id);
    return product?.ToResponseDto();
}

Consistency matters. When every async method follows the same naming and style, the codebase feels predictable.


Keep exceptions and error handling organized

Error handling should not be scattered across every file in a different style.

A better practice is to centralize common exception handling.

For example, create a middleware:

public class ExceptionMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<ExceptionMiddleware> _logger;

    public ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "An unexpected error occurred.");
            context.Response.StatusCode = StatusCodes.Status500InternalServerError;
            await context.Response.WriteAsJsonAsync(new
            {
                message = "An unexpected error occurred."
            });
        }
    }
}

Then register it once.

This is cleaner than handling the same kind of error separately in every controller.


Use consistent indentation and formatting

File structure is not only about folders and namespaces. It is also about how code looks inside each file.

Good formatting makes the file easier to scan.

Use:

  • consistent indentation,

  • one blank line between logical blocks,

  • braces in a consistent style,

  • readable line length,

  • aligned constructor injections,

  • logical grouping of methods.

Example:

public class ProductService : IProductService
{
    private readonly IProductRepository _productRepository;
    private readonly IProductValidator _productValidator;

    public ProductService(
        IProductRepository productRepository,
        IProductValidator productValidator)
    {
        _productRepository = productRepository;
        _productValidator = productValidator;
    }

    public async Task<ProductResponseDto> CreateAsync(ProductCreateDto dto)
    {
        _productValidator.Validate(dto);

        var product = dto.ToEntity();
        await _productRepository.AddAsync(product);

        return product.ToResponseDto();
    }
}

Clean formatting reduces fatigue. That matters more than many people realize.

When code is visually organized, developers make fewer mistakes reading it.


Use top-level folders with purpose

For a real project, a top-level C# structure might look like this:

src/
├── MyApp.Api/
├── MyApp.Application/
├── MyApp.Domain/
├── MyApp.Infrastructure/
└── MyApp.Tests/

This is a common and professional solution for larger applications.

What each project does

MyApp.Api

  • controllers

  • endpoints

  • middleware

  • request/response handling

MyApp.Application

  • services

  • use cases

  • DTOs

  • validators

  • interfaces

MyApp.Domain

  • entities

  • core business rules

  • value objects

  • domain events

MyApp.Infrastructure

  • database access

  • external services

  • file systems

  • email providers

MyApp.Tests

  • unit tests

  • integration tests

This separation makes the project easier to scale.

It also helps teams work in parallel with less overlap.


Example of a clean multi-project structure

Here is a practical example for a product management system:

src/
├── ProductManager.Api/
│   ├── Controllers/
│   │   └── ProductsController.cs
│   ├── Middleware/
│   │   └── ExceptionMiddleware.cs
│   └── Program.cs
│
├── ProductManager.Application/
│   ├── DTOs/
│   │   ├── ProductCreateDto.cs
│   │   └── ProductResponseDto.cs
│   ├── Interfaces/
│   │   ├── IProductRepository.cs
│   │   ├── IProductService.cs
│   │   └── IProductValidator.cs
│   ├── Services/
│   │   └── ProductService.cs
│   ├── Validators/
│   │   └── ProductValidator.cs
│   └── Mapping/
│       └── ProductMappingExtensions.cs
│
├── ProductManager.Domain/
│   ├── Entities/
│   │   └── Product.cs
│   └── ValueObjects/
│       └── Money.cs
│
├── ProductManager.Infrastructure/
│   ├── Data/
│   │   └── AppDbContext.cs
│   └── Repositories/
│       └── ProductRepository.cs
│
└── ProductManager.Tests/
    ├── Services/
    │   └── ProductServiceTests.cs
    └── Controllers/
        └── ProductsControllerTests.cs

This structure is not the only correct one, but it is clean, scalable, and easy to explain to a new developer.


Use vertical slicing when it fits

Some teams prefer vertical slicing, especially for APIs and large applications.

Instead of grouping all services in one place and all controllers in another, you group by feature.

Example:

Features/
├── Products/
│   ├── CreateProduct/
│   │   ├── Endpoint.cs
│   │   ├── Request.cs
│   │   ├── Response.cs
│   │   └── Handler.cs
│   ├── GetProduct/
│   └── UpdateProduct/
├── Orders/
│   ├── CreateOrder/
│   └── GetOrder/

This style keeps the full flow of one feature close together.

It can be especially useful when your project has many features that evolve independently.


Keep generated code separate from hand-written code

Generated code should usually live apart from your custom code.

This prevents confusion and accidental edits.

Examples of generated code include:

  • EF migrations,

  • auto-generated clients,

  • scaffolded files,

  • code produced by tools.

Keeping generated files separated helps you avoid mixing machine-generated content with business logic.

A simple rule is:

  • generated code stays in clearly marked folders,

  • handwritten code stays in your normal feature or layer folders.

That makes maintenance easier later.


Avoid deep nesting unless it is truly useful

Too many nested folders can become annoying.

For example, this can be excessive:

Services/Products/Management/Operations/Internal/Helpers/

At some point, deep nesting slows people down.

A good folder hierarchy is deep enough to be meaningful, but not so deep that nobody remembers where anything lives.

Ask yourself:

“Does this extra folder improve clarity, or am I just being overly specific?”

Usually, clarity wins over complexity.


Design for readability first

A codebase is read much more often than it is written.

This is why readability matters so much.

A simple class with obvious names can be better than a clever class with overly compact logic.

For example, this is readable:

public bool IsEligibleForDiscount(Customer customer)
{
    if (!customer.IsActive)
        return false;

    if (customer.TotalOrders < 5)
        return false;

    return true;
}

This is harder to read:

public bool IsEligibleForDiscount(Customer c)
{
    return c.IsActive && c.TotalOrders >= 5;
}

The second version is shorter, but the first may be easier to understand in a business context.

Structure should support comprehension, not just brevity.


Keep comments purposeful

Comments should explain why something exists, not repeat the code.

Bad comment:

// Increment i by 1
i++;

Useful comment:

// This discount applies only to active customers with at least 5 orders.
if (customer.IsActive && customer.TotalOrders >= 5)
{
    ApplyDiscount();
}

When your structure is clean, you usually need fewer comments because the code already tells the story.

That is a good sign.


Refactor duplicated code into shared abstractions

Copy-paste is one of the biggest enemies of clean structure.

If you see the same logic in multiple files, ask whether it belongs in a shared service, helper, base class, or extension method.

For example, if three services all format phone numbers the same way, that logic probably belongs in one class.

public class PhoneNumberFormatter
{
    public string Format(string phoneNumber)
    {
        return phoneNumber.Replace(" ", "").Trim();
    }
}

Then inject and reuse it where needed.

The goal is not to make everything “shared” by default. The goal is to reduce duplication without creating unnecessary complexity.


Keep constructors focused

A class with too many dependencies is often a sign that the class is doing too much.

For example:

public class OrderService
{
    public OrderService(
        IOrderRepository orderRepository,
        IEmailService emailService,
        IPaymentService paymentService,
        IInventoryService inventoryService,
        IShippingService shippingService,
        ILogger<OrderService> logger,
        IAuditService auditService)
    {
    }
}

This constructor is a warning sign.

It does not automatically mean the design is wrong, but it should make you stop and ask:

  • Is this class too large?

  • Can some responsibilities be moved elsewhere?

  • Are these services really all part of the same use case?

Good structure often reduces the number of dependencies per class.


Think in terms of domains, not just files

A strong C# structure is not just about technical organization. It is about reflecting the business domain.

For example, in an e-commerce system, you might have:

  • Products

  • Orders

  • Payments

  • Shipping

  • Customers

  • Discounts

Those are real concepts.

Your code structure should respect them.

When files and folders mirror the domain, developers understand the system faster because the structure makes sense in business terms, not just programming terms.

That is especially important for teams that include product managers, analysts, or new developers.


A realistic example of a feature folder

Here is a small feature-based structure for creating orders:

Features/Orders/Create/
├── CreateOrderRequest.cs
├── CreateOrderResponse.cs
├── CreateOrderValidator.cs
├── CreateOrderHandler.cs
└── CreateOrderEndpoint.cs

Request

public class CreateOrderRequest
{
    public int CustomerId { get; set; }
    public List<int> ProductIds { get; set; } = new();
}

Response

public class CreateOrderResponse
{
    public int OrderId { get; set; }
    public string Status { get; set; } = string.Empty;
}

Handler

public class CreateOrderHandler
{
    private readonly IOrderRepository _orderRepository;

    public CreateOrderHandler(IOrderRepository orderRepository)
    {
        _orderRepository = orderRepository;
    }

    public async Task<CreateOrderResponse> HandleAsync(CreateOrderRequest request)
    {
        var order = new Order
        {
            CustomerId = request.CustomerId,
            Status = "Created"
        };

        await _orderRepository.AddAsync(order);

        return new CreateOrderResponse
        {
            OrderId = order.Id,
            Status = order.Status
        };
    }
}

This keeps all order creation logic grouped together.

That means less searching and less confusion.


Don’t overuse static classes

Static classes are useful in the right place, but they should not become a crutch.

Use them for stateless utilities and extension methods when appropriate.

Avoid using static classes as a place to dump everything just because it is convenient.

A static class with unrelated methods is often a sign that the structure needs improvement.

Ask whether the class represents a real concept or just a place to store functions.

That distinction matters more than it seems.


Use the right level of abstraction

A good structure has enough abstraction to keep code clean, but not so much that it becomes abstract for no reason.

Too little abstraction:

  • duplicated code,

  • repeated logic,

  • hard-to-change files.

Too much abstraction:

  • too many layers,

  • unclear flow,

  • difficult debugging.

The best structure is practical.

A small project may not need full domain-driven structure.
A large enterprise application probably does.

The right answer depends on size, complexity, and team needs.


What a bad structure often looks like

Here are some warning signs:

  • many classes in one file,

  • folders named Stuff, Common, or Misc,

  • controllers containing business logic,

  • services containing database queries and email logic together,

  • DTOs mixed with entities in the same folder without reason,

  • duplicate code across multiple files,

  • giant Program.cs,

  • random helper methods with no clear purpose,

  • no test structure,

  • inconsistent naming conventions.

If several of these are present, the project probably needs refactoring.

The good news is that structure can be improved incrementally. You do not need to rebuild everything at once.


A practical checklist for better C# file structure

When starting a new C# project or improving an existing one, use this mental checklist:

  • Does each file have a clear purpose?

  • Does each folder contain one type of responsibility?

  • Are names consistent and descriptive?

  • Are controllers thin?

  • Is business logic in the service layer?

  • Are DTOs separate from domain models?

  • Are tests organized logically?

  • Are async methods named consistently?

  • Are dependencies reasonable?

  • Is Program.cs clean enough to read quickly?

  • Are utilities truly reusable, or just miscellaneous leftovers?

If the answer to most of these is yes, your project is in good shape.


A simple example of a well-structured flow

Let us walk through a clean flow for creating a product.

1. The controller receives the request

[HttpPost]
public async Task<ActionResult<ProductResponseDto>> Create(ProductCreateDto dto)
{
    var product = await _productService.CreateAsync(dto);
    return Ok(product);
}

2. The service handles business logic

public async Task<ProductResponseDto> CreateAsync(ProductCreateDto dto)
{
    _productValidator.Validate(dto);

    var product = dto.ToEntity();
    await _productRepository.AddAsync(product);

    return product.ToResponseDto();
}

3. The repository saves the entity

public async Task AddAsync(Product product)
{
    _context.Products.Add(product);
    await _context.SaveChangesAsync();
}

4. Mapping converts data cleanly

public static ProductResponseDto ToResponseDto(this Product product)
{
    return new ProductResponseDto
    {
        Id = product.Id,
        Name = product.Name,
        Price = product.Price
    };
}

This is simple, readable, and easy to maintain.

That is what good structure feels like in practice: not fancy, not noisy, just clear.


Human advice from real project experience

A codebase often gets messy for understandable reasons.

Teams move fast.
Deadlines are real.
Features stack up.
Refactoring feels expensive.
And every developer, at some point, tells themselves, “I’ll clean it up later.”

Later usually comes with interest.

That is why structure is worth caring about early. Not because perfect architecture is the goal, but because future you will thank present you for not creating unnecessary pain.

A clean project is not one that tries to impress people with complexity. It is one that feels calm to work in.

When a project is well structured, developers can breathe. They can find things. They can change things. They can trust the codebase a little more.

That trust matters.


Final thoughts

C# file structure is not just a housekeeping detail. It is part of writing maintainable software.

A good structure helps you:

  • keep responsibilities separate,

  • reduce duplication,

  • improve readability,

  • make testing easier,

  • scale the application with confidence,

  • and keep the codebase healthy over time.

Start with simple rules:

  • one class per file,

  • meaningful names,

  • clear folders,

  • aligned namespaces,

  • thin controllers,

  • focused services,

  • separate DTOs and models,

  • organized tests,

  • and no “everything” folders unless they truly make sense.

Then grow from there.

The best structure is the one your team can understand quickly and maintain consistently.

A project does not become clean by accident. It becomes clean because someone chose clarity on purpose.

And that choice pays off every single day.