6 Pillars of code quality

I’ve recently been reading 'Good Code, Bad Code' by Manning, and the section on the 6 Pillars of Code Quality really resonated with me. I figured it would be a great idea to summarize my takeaways in this blog post. To make things more practical, I’ve included code examples in .NET (C#), Spring, and Go based on my own understanding of these concepts.

ฺNote: For Thai Version, you can go to this blog

Making Code More Readable

One of the core principles I've gathered is that code should be written for humans to understand, not just for machines to execute. Here are some key takeaways on enhancing readability:

📌 The "One Screen" Rule

A common rule of thumb is that a Function or Method should not exceed one screen height (roughly 20 lines of code) - Blog (In Thai). If a method spans multiple pages, it becomes significantly harder to track the logic and maintain the mental model of what’s happening.

📌 Meaningful Naming (Ubiquitous Language)

Naming should reflect the Business Logic it serves. This applies to everything from variables and methods to cloud infrastructure naming conventions.

  • Code: Instead of vague names like CalcA1(), use descriptive ones like CalculateBondPriceThorFormula(). The latter tells you exactly what it does and why.
  • Infrastructure: Avoid generic names like VNET1 or APP1. Without a mapping document, no one knows if it's for Dev or Prod, or which service it belongs to. Use names that provide context at a glance.

📌 Small Methods & Single Responsibility

Keep your methods short and focused on a Single Responsibility.

  • If a function is doing too many things, Extract the logic into smaller, helper methods.
  • Benefit: Smaller functions are infinitely easier to Unit Test. You can isolate specific behaviors without setting up a massive state.

📌The Truth about Comments

While comments are important, they can be a double-edged sword.

  • Code as Documentation: If your code is expressive and backed by solid Unit Tests, the need for comments decreases.
  • The "Outdated Comment" Trap: We’ve all seen it—code that was updated 5 years ago, but a comment from 10 years ago still sits there, completely contradicting the current logic.
  • Rule: Write code so clear that it explains itself. Use comments to explain the "Why" (the intent), not the "How" (the implementation).

Ideally, your code should be self-documenting. By choosing expressive method names that clearly convey intent, you eliminate the risk of 'Comment-Code Mismatch'—where the logic evolves but the comments stay stuck in the past

Sample Code

  • C#
// C# - readable example
public class Order
{
    public int Id { get; init; }
    public decimal Amount { get; init; }
}

public interface IPaymentGateway
{
    bool Charge(decimal amount);
}

public class OrderService
{
    private readonly IPaymentGateway _gateway;

    public OrderService(IPaymentGateway gateway) => _gateway = gateway;

    // ProcessOrder - Making Code More Readable for Business
    public bool ProcessOrder(Order? order)
    {
        if (order == null) throw new ArgumentNullException(nameof(order));
        if (order.Amount <= 0) return false;

        return _gateway.Charge(order.Amount);
    }
}
  • Spring
// Spring - readable example
@Service
public class OrderService {
    private final PaymentGateway paymentGateway;

    public OrderService(PaymentGateway paymentGateway) {
        this.paymentGateway = paymentGateway;
    }

    // ProcessOrder - Making Code More Readable for Business
    public boolean processOrder(OrderDto order) {
        if (order == null) throw new IllegalArgumentException("order required");
        if (order.getAmount().compareTo(BigDecimal.ZERO) <= 0) return false;

        return paymentGateway.charge(order.getAmount());
    }
}
  • Golang
// Go - readable example
package orders

type Order struct {
    ID     int
    Amount float64
}

type PaymentGateway interface {
    Charge(amount float64) bool
}

type Service struct {
    gateway PaymentGateway
}

func NewService(g PaymentGateway) *Service {
    return &Service{gateway: g}
}

func (s *Service) ProcessOrder(o *Order) bool {
    if o == nil || o.Amount <= 0 {
        return false
    }
    return s.gateway.Charge(o.Amount)
}

Avoid Surprises: Aim for Determinism

To write high-quality code, your functions should be predictable. If you provide the same input, you should always get the same output. Without this predictability, you’ll encounter "Flaky Tests"—tests that pass sometimes and fail others without any changes to the code.

A major culprit here is Side-Effects. A side-effect occurs when a function does more than just return a value; it modifies something outside its local scope.

Common Sources of Side-Effects & Surprises:

  • State Mutation: Changing properties within a Class/DTO or modifying global variables. It’s crucial to understand the difference between Pass-by-Reference vs. Pass-by-Value to avoid unintended data changes.
  • Non-Deterministic Functions: Using values that change constantly, such as DateTime.Now, Math.Random(), or GUIDs. These make functions impossible to test consistently unless they are properly abstracted or injected.
  • Improper Null Handling: What happens when a function receives or returns null? If not handled correctly, it leads to the dreaded "Null Reference Exception." You should define clear contracts on what to return (e.g., Empty Collections, Optional types) so the caller doesn't crash.
  • Hidden Dependencies: Functions that rely on process.env (Environment Variables) or throw unexpected Exceptions can create hidden side-effects that are hard to trace.
  • External I/O: Any interaction with the outside world—Reading/Writing files, calling an API, or querying a Database. These are inherently "side-effects" and should be isolated from your core business logic.

Sample Code

  • C#
// Avoid surprises: do not mutate input and return clear result
public class StringUtils
{
    // ไม่เปลี่ยน string ที่ส่งมา (immutable)
    public static string Normalize(string? input)
    {
        if (input == null) return string.Empty;
        return input.Trim().ToLowerInvariant();
    }
}
  • Spring
// Avoid surprises: return Optional instead of nulls, explicit transactional boundaries
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
}

@Service
@Transactional(readOnly = true)
public class UserService {
    private final UserRepository repo;
    public UserService(UserRepository repo) { this.repo = repo; }

    public Optional<UserDto> findByEmail(String email) {
        return repo.findByEmail(email).map(UserDto::fromEntity);
    }
}
  • Golang
// Avoid surprises: explicit error returns, no panics
func ParseAmount(s string) (float64, error) {
    if s == "" {
        return 0, fmt.Errorf("empty amount")
    }
    a, err := strconv.ParseFloat(s, 64)
    if err != nil {
        return 0, fmt.Errorf("invalid amount: %w", err)
    }
    return a, nil
}

Update: since during a code review I came across a teammate's sample case related to DateTime — it was in C# and done incorrectly, because we can't control the state of DateTime.Now, and it's also hard to test."

// C#
public void UpdateStatus() { if (DateTime.Now.Hour > 17) globalStatus = "Closed"; }

// Golang 
func UpdateStatus() { if time.Now().Hour() > 17 { globalStatus = "Closed" } }

The improved version would look something like this — move it to be a parameter instead.

  • C#
public string GetStatus(DateTime currentTime) => currentTime.Hour > 17 ? "Closed" : "Open";
  • Spring
public String getStatus(LocalDateTime currentTime) {
    return currentTime.getHour() > 17 ? "Closed" : "Open";
}
  • Golang
func GetStatus(currentTime time.Time) string {
    if currentTime.Hour() > 17 {
        return "Closed"
    }
    return "Open"
}

Make Code Hard to Misuse

A great API—whether it’s a REST endpoint or a simple Method—should be resilient to incorrect usage. As the saying goes: "Design your APIs so that they are easy to use correctly and hard to use incorrectly."

Here is how you can build "Bulletproof" code:

📌 Make Illegal States Unrepresentable - Use the Type System to enforce business rules. Instead of using a string for an Email or a double for a Latitude, To Check at Compile Time.

📌 Fail Fast & Loudly - If something is wrong, catch it early.

  • Compile-time: Catch errors during development (via Type-safety).
  • Runtime: If a validation fails, throw a clear, descriptive error message immediately. Don't let the code continue running with "junk" data.

📌 Provide Safe Defaults - When a user doesn't provide a specific configuration, the system should default to the safest possible option, not the most dangerous one.

📌 Small Surface Area (Reduce Cognitive Load)

  • Encapsulation: Hide internal logic.
  • Patterns: Use Factories or Builders to guide the user through complex object creation, hiding the "messy" details.

📌 Explicit over Implicit - Be clear about what is happening. Avoid "magic" or hidden behaviors that happen behind the scenes. Lean on Type-safety so the developer knows exactly what is required.

📌 Encapsulate Mutability - Hide state changes from the outside world. Prefer Immutable objects or Transactional updates (all or nothing). If an object can’t be changed after it's created, you eliminate a whole category of bugs related to shared state.

📌 Prefer Types to Docs - Documentation can get outdated (just like comments!). A strong Method Signature or Strongly Typed return value is a self-documenting contract that the compiler enforces. If the Type tells you how to use it, you don't need to go hunting through a Readme file.

Sample Code

  • C#
// Make misuse harder: immutable value object + factory
public class Email
{
    public string Value { get; }
    private Email(string value) => Value = value;

    public static Email Create(string email)
    {
        if (string.IsNullOrWhiteSpace(email)) throw new ArgumentException("email empty");
        if (!email.Contains("@")) throw new ArgumentException("invalid email");
        return new Email(email.Trim().ToLowerInvariant());
    }
}
  • Spring
// Make misuse harder: DTO validation + @Validated service
public class CreateUserRequest {
    @NotBlank
    @Email
    private String email;
    // getters/setters
}

@RestController
@RequestMapping("/users")
public class UserController {
    @PostMapping
    public ResponseEntity<Void> createUser(@Valid @RequestBody CreateUserRequest req) {
        // Spring will validate automatically; invalid payload -> 400
        // ... create user
        return ResponseEntity.status(HttpStatus.CREATED).build();
    }
}
  • Golang
// Make misuse harder: unexported fields + constructor that validates
type Config struct {
    host string
    port int
}

func NewConfig(host string, port int) (*Config, error) {
    if host == "" { return nil, fmt.Errorf("host required") }
    if port <= 0 { return nil, fmt.Errorf("invalid port") }
    return &Config{host: host, port: port}, nil
}

// users outside package cannot set fields directly

Update: P'Pui (Somkiat.cc) suggested me on my Facebook post that Java now has a Record type, so give it a try. I then checked and C# also has it, so I've added a Bad example in C#.

// Bad (Primitive Obsession) in C# / But Same in Java
public void ProcessPayment(decimal amount, string currency) 
{
    // Add Validation Check
    if (amount < 0) throw new ArgumentException("Amount cannot be negative");
    // ... logic ...
}

And the improvements cover C#, Spring (Java), and Golang as well.

  • C#
public readonly record struct Money
{
    public decimal Amount { get; init; }
    public string Currency { get; init; }

    public Money(decimal amount, string currency)
    {
        if (amount < 0) throw new ArgumentException("Money cannot be negative");
        if (string.IsNullOrWhiteSpace(currency)) throw new ArgumentException("Currency is required");
        
        Amount = amount;
        Currency = currency;
    }

    // You can Encapsulation Logic Business Logic
    public Money Add(Money other)
    {
        if (this.Currency != other.Currency)
            throw new InvalidOperationException("Cannot add different currencies");
            
        return new Money(this.Amount + other.Amount, this.Currency);
    }
}
  • Java
public record Money(BigDecimal amount, String currency) {
    public Money {
        if (amount.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("Amount cannot be negative");
        }
        if (currency == null || currency.isBlank()) {
            throw new IllegalArgumentException("Currency is required");
        }
    }

    // You can Encapsulation Logic Business Logic
    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new IllegalStateException("Currencies do not match");
        }
        return new Money(this.amount.add(other.amount), this.currency);
    }
}
  • Golang
package finance

import (
	"errors"
	"fmt"
)

// Money is a value object. Fields are unexported/private (lowercase) 
type Money struct {
	amount   float64
	currency string
}

// NewMoney acts as a "Fail Fast" factory to create a object from outside this package.
func NewMoney(amount float64, currency string) (Money, error) {
	if amount < 0 {
		return Money{}, errors.New("amount cannot be negative")
	}
	if currency == "" {
		return Money{}, errors.New("currency is required")
	}
	return Money{amount: amount, currency: currency}, nil
}

// Methods as business logic.
func (m Money) Add(other Money) (Money, error) {
	if m.currency != other.currency {
		return Money{}, fmt.Errorf("cannot add %s to %s", other.currency, m.currency)
	}
	return Money{amount: m.amount + other.amount, currency: m.currency}, nil
}

// Getter methods provide read-only access, maintaining immutability.
func (m Money) Amount() float64 { return m.amount }
func (m Money) Currency() string { return m.currency }

Make Code Modular

Writing clean functions is just the beginning; how we organize those functions into a system is what determines long-term maintainability. Whether you are working with packages in Go, modules in Java/Spring, or projects in .NET, the goal is the same: Manage complexity by breaking it down.

📌 Choosing the Right Architectural Style

The way you modularize depends on your system's scale:

  • Layered Architecture: The classic approach (Controller → Service → Repository → DTO). Great for smaller, straightforward apps.
  • Clean Architecture & Modular Monoliths: A step up for growing systems. It keeps the core business logic independent of external frameworks and UI.
  • Distributed Systems: For large-scale needs, consider Microservices (service-per-capability), Event-driven architectures, or CQRS + Event Sourcing to handle complex data flows.

📌 How to Define Your Boundaries

Separating code isn't just about moving files; it’s about logical boundaries. Use these concepts as your guide:

  • Domain-Driven Design (DDD): Use DDD to identify your Core Domain and Bounded Contexts. Look for where API contracts and data ownership overlap. If multiple services are fighting over the same data, your boundaries might be misplaced.
  • Separation of Concerns (SoC): Separate your code by logical responsibility (UI, Application Logic, Domain, and Infrastructure). Aim for High Cohesion (keeping related things together) and Low Coupling (minimizing dependencies between parts).
  • Single Responsibility Principle (SRP): This applies to modules and classes just as much as methods. Each module should have one clear responsibility and only one reason to change.
  • Dependency Injection (DI): Use DI to make your code "pluggable." It eliminates the "ceremony" of complex setups, making Unit Testing significantly easier because you can swap infrastructure for mocks effortlessly.
  • KISS (Keep It Simple, Stupid) & YAGNI (You Ain't Gonna Need It): Don't over-engineer. Don't build a distributed system if a simple package-based monolith works. Avoid complexity until the business requirements truly demand it.

Sample Code

  • C#
//interface + DI
public interface IOrderRepository { Order Get(int id); void Save(Order o); }
public class OrderRepository : IOrderRepository { /* db logic */ }

public class OrderController
{
    private readonly OrderService _service;
    public OrderController(OrderService service) { _service = service; }

    public IActionResult PlaceOrder(OrderDto dto) {
        // controller delegates to service
        _service.ProcessOrder(dto.ToOrder());
        return new Ok();
    }
}
  • Spring
//Controller -> Service -> Repository
@RestController
@RequestMapping("/orders")
public class OrderController {
    private final OrderService service;
    public OrderController(OrderService service) { this.service = service; }

    @PostMapping
    public ResponseEntity<Void> placeOrder(@RequestBody OrderDto dto) {
        service.placeOrder(dto);
        return ResponseEntity.ok().build();
    }
}
  • Golang
// packages: package orders and package payment
// orders.Service depends on payment.Gateway interface
package orders

type Gateway interface { Charge(amount float64) bool }

type Service struct {
    gateway Gateway
}
// in other package implement Gateway, provide to Service via NewService

Modular isn't just about organization; it's about cognitive load. By isolating concerns, a developer can focus on one piece of the puzzle at a time without worrying about the entire system collapsing

Make Code Reusable

The goal of reusability isn't just to "copy-paste" less; it's about building building blocks that are robust, tested, and generic enough to solve similar problems across your entire system.

📌 Small and Specific Components

Keep your Functions, Methods, and APIs small and highly focused. When a function does only one thing, it becomes a "Utility" or "Common" component that other parts of the system can easily consume without bringing in unnecessary baggage.

📌 Generics & Parametrization

If you find yourself writing the same logic for different data types (e.g., a Repository for Users and a Repository for Products), it's time to use Generics. This allows you to write the logic once and parameterize the type later.

Some people might be confused by this code example, why I suddenly use Generics? Let me include a before example code first.

// Sample bad code  C# Version / but can apply in other language
public class UserResult
{
    public bool IsSuccess { get; }
    public User Value { get; }
    public string Error { get; }
}

public class OrderResult
{
    public bool IsSuccess { get; }
    public Order Value { get; }
    public string Error { get; }
}

public class ProductResult
{
    public bool IsSuccess { get; }
    public Product Value { get; }
    public string Error { get; }
}

Improve Code, Example

  • C#
//generic Result<T> for operations
public class Result<T>
{
    public bool IsSuccess { get; }
    public T Value { get; }
    public string Error { get; }
    // constructors/factories...
}
  • Spring
//generic paging/util component
public class PageResult<T> {
    private List<T> items;
    private int total;
    // getters/setters
}
  • Golang - but this one is subjective. Some see it as 'Simple is the best.' When I was writing the blog, I was also wondering why it doesn't look like dotnet / Spring coding style
//Required Go 1.18+ generics
package slices

func Map[T any, R any](in []T, fn func(T) R) []R {
    out := make([]R, 0, len(in))
    for _, v := range in { out = append(out, fn(v)) }
    return out
}

Make Code Testable and Test It Properly

Writing code that "works" is only half the battle; the other half is ensuring it is testable and, more importantly, actually tested.

I’ve often encountered projects—especially in .NET—where the Code Coverage is high, but the tests lack meaningful Assert statements. High coverage without assertions is a "false sense of security" and ultimately useless. To build a reliable test suite, keep these principles in mind:

📌 Break Methods Down to the Smallest Unit

The golden rule: "Small functions are easy to test." If a method tries to do too much, the setup required to test even a single scenario becomes a nightmare. By keeping methods focused and granular, you can test specific logic in isolation.

📌 Decouple via Dependency Injection (DI)

Use Dependency Injection to separate your core logic from external dependencies (like databases or third-party APIs). This allows you to use Mocks during testing, ensuring that your Unit Tests are testing your code, not your infrastructure.

📌 Test Both "Happy Paths" and "Edge Cases" Don't just test if the code works when everything goes right. A robust test suite must cover:

  • Business Error Cases: How does the system handle insufficient funds or out-of-stock items?
  • Boundary Values: What happens at the limits? Test with zero, negative numbers, or maximum allowed values.

📌 Use Table-Driven Tests defining a table of inputs and expected outputs, then looping through them in a single test function.

Sample Code

  • C#
// C# unit test using xUnit and Moq
public class OrderServiceTests
{
    [Fact]
    public void ProcessOrder_ValidOrder_ChargesGateway()
    {
        var mock = new Mock<IPaymentGateway>();
        mock.Setup(g => g.Charge(It.IsAny<decimal>())).Returns(true);

        var svc = new OrderService(mock.Object);
        var order = new Order { Id = 1, Amount = 100m };

        var result = svc.ProcessOrder(order);

        Assert.True(result);
        mock.Verify(g => g.Charge(100m), Times.Once);
    }
}
  • Spring
// Spring controller unit test
@WebMvcTest(OrderController.class)
class OrderControllerTest {
    @Autowired MockMvc mvc;
    @MockBean OrderService service;

    @Test
    void postOrder_callsService() throws Exception {
        String json = "{\"id\":1,\"amount\":100}";
        mvc.perform(post("/orders").contentType(MediaType.APPLICATION_JSON).content(json))
           .andExpect(status().isOk());
        verify(service).placeOrder(any());
    }
}
  • Golang - Table-Driven Tests
// Go unit tests (table-driven)
func TestParseAmount(t *testing.T) {
    tests := []struct{
        input string
        want float64
        wantErr bool
    }{
        {"12.5", 12.5, false},
        {"", 0, true},
        {"abc", 0, true},
    }

    for _, tt := range tests {
        got, err := ParseAmount(tt.input)
        if (err != nil) != tt.wantErr {
            t.Fatalf("input=%q expected err=%v got=%v", tt.input, tt.wantErr, err)
        }
        if !tt.wantErr && got != tt.want {
            t.Fatalf("input=%q want %v got %v", tt.input, tt.want, got)
        }
    }
}

From my experience, everything has trade-offs. For example:

  • Make code modular / Make code readable — sometimes when we split things up too much, we end up with many files, and anyone reading the code has to jump back and forth, which can introduce Cognitive Load issues. You might apply the Rule of Three here — only split it out if it's repeated more than 3 times, for instance.
  • Or from a Performance / Readable / Testable perspective — some code that's been optimized for a specific task might end up being harder to read or harder to test.

You need to balance these well.

Additionally, many of these things don't have to be done manually — you can bring in automated tools to help check, such as a Linter or setting up a Quality Gate in CI/CD."


Discover more from naiwaen@DebuggingSoft

Subscribe to get the latest posts sent to your email.