Tiger Style: A Philosophy for Robust Software Development

Software Engineering Best Practices

Tiger Style is a comprehensive coding philosophy centered on safety, performance, and developer experience. It promotes disciplined engineering to build robust, efficient, and maintainable software through practical design goals and a commitment to zero technical debt.

Tiger Style: A Philosophy for Robust Software Development

Tiger Style is a comprehensive coding philosophy centered on safety, performance, and developer experience. Inspired by the rigorous engineering practices of TigerBeetle, it advocates for building robust, efficient, and maintainable software through disciplined development. More than just a set of coding standards, Tiger Style offers a practical approach that, by prioritizing these core principles, helps create reliable, efficient, and enjoyable code.

Core Principles

Safety

Safety forms the bedrock of Tiger Style. It entails writing code that performs reliably in all scenarios, significantly reducing the risk of errors. A strong focus on safety ensures your software is trustworthy and dependable.

Performance

Performance revolves around the efficient use of resources to deliver fast and responsive software. Prioritizing performance early in the development lifecycle helps in designing systems that consistently meet or exceed user expectations.

Developer Experience

A positive developer experience directly translates to improved code quality and long-term maintainability. Readable, easy-to-work-with code fosters collaboration and minimizes errors, cultivating a healthier codebase that endures over time.

Design Goals

The design goals of Tiger Style emphasize constructing software that is inherently safe, fast, and easy to maintain.

Safety in Design

Achieving safety in coding relies on clear, structured practices that prevent errors and fortify the codebase. It means writing code that functions correctly in diverse situations and identifies issues proactively. By integrating safety measures, you develop reliable software that behaves predictably, irrespective of its operating environment.

Control and Limits

Predictable control flow and bounded system resources are crucial for safe execution.

  • Simple and Explicit Control Flow: Favor straightforward control structures over complex logic. Simple control flow makes code easier to understand and reduces the risk of bugs. Where possible, avoid recursion to keep execution bounded and predictable, preventing stack overflows and uncontrolled resource use.
  • Set Fixed Limits: Establish explicit upper bounds for loops, queues, and other data structures. Fixed limits prevent infinite loops and uncontrolled resource consumption, adhering to the "fail-fast" principle. This approach helps catch issues early and maintains system stability.
  • Limit Function Length: Keep functions concise, ideally under 70 lines. Shorter functions are easier to understand, test, and debug. They promote single responsibility, where each function performs one task effectively, leading to a more modular and maintainable codebase.
  • Centralize Control Flow: Position switch or if statements primarily within the main parent function, delegating non-branching logic to helper functions. Allow the parent function to manage state, using helpers to calculate changes without directly applying them. Keep leaf functions pure and focused on specific computations. This division of responsibility ensures one function controls flow while others handle specific logic.

Memory and Types

Clear and consistent handling of memory and types is vital for writing safe, portable code.

  • Use Explicitly Sized Types: Employ data types with explicit sizes, such as u32 or i64, instead of architecture-dependent types like usize. This ensures consistent behavior across platforms and prevents size-related errors, enhancing portability and reliability.
  • Static Memory Allocation: Allocate all necessary memory during startup and avoid dynamic memory allocation after initialization. Runtime dynamic allocation can lead to unpredictable behavior, fragmentation, and memory leaks. Static allocation simplifies memory management and increases predictability.
  • Minimize Variable Scope: Declare variables in the smallest possible scope. Limiting scope reduces the risk of unintended interactions and misuse. It also improves code readability and maintainability by keeping variables within their relevant context.

Error Handling

Correct error handling ensures the system remains robust and reliable under all conditions.

  • Use Assertions: Utilize assertions to verify that conditions hold true at specific points in the code. Assertions act as internal checks, boosting robustness and simplifying debugging.
    • Assert Function Arguments and Return Values: Verify that functions receive and return expected values.
    • Validate Invariants: Maintain critical conditions by asserting invariants during execution.
    • Use Pair Assertions: Check critical data at multiple points to detect inconsistencies early.
    • Fail Fast on Programmer Errors: Immediately detect unexpected conditions, stopping faulty code from continuing.
  • Handle All Errors: Explicitly check and handle every error. Ignoring errors can result in undefined behavior, security vulnerabilities, or crashes. Write comprehensive tests for error-handling code to ensure your application functions correctly in all cases.
  • Treat Compiler Warnings as Errors: Configure the strictest compiler settings and treat all warnings as errors. Warnings often indicate potential issues that could lead to bugs. Fixing them promptly enhances code quality and reliability.
  • Avoid Implicit Defaults: Explicitly specify options when invoking library functions instead of relying on defaults. Implicit defaults can vary between library versions or environments, causing inconsistent behavior. Being explicit improves code clarity and stability.

Performance in Design

Performance involves efficiently utilizing resources to deliver fast and responsive software. Prioritizing performance early in the design phase helps create systems that meet or surpass user expectations without unnecessary overhead.

Design for Performance

Early design decisions profoundly impact performance. Thoughtful planning helps prevent bottlenecks later on.

  • Design for Performance Early: Integrate performance considerations into the initial design phase. Early architectural decisions significantly influence overall performance, and planning ahead ensures the avoidance of bottlenecks and improved resource efficiency.
  • Napkin Math: Employ quick, back-of-the-envelope calculations to estimate system performance and resource costs. For instance, estimate the time required to read 1 GB of data from memory or the expected storage cost for logging 100,000 requests per second. This helps establish practical expectations early and identify potential bottlenecks before they arise.
  • Batch Operations: Amortize the cost of expensive operations by processing multiple items simultaneously. Batching reduces overhead per item, increases throughput, and is particularly beneficial for I/O-bound operations.

Efficient Resource Use

Focus on optimizing the slowest resources, typically in the following order:

  • Network: Optimize data transfer and reduce latency.
  • Disk: Enhance I/O operations and manage storage efficiently.
  • Memory: Utilize memory effectively to prevent leaks and overuse.
  • CPU: Increase computational efficiency and reduce processing time.

Predictability

Writing predictable code enhances performance by reducing CPU cache misses and optimizing branch prediction.

  • Ensure Predictability: Write code with predictable execution paths. Predictable code leverages CPU caching and branch prediction more effectively, leading to improved performance. Avoid patterns that cause frequent cache misses or unpredictable branching, as these degrade performance.
  • Reduce Compiler Dependence: Do not rely solely on compiler optimizations for performance. Write clear, efficient code that does not depend on specific compiler behavior. Be explicit in performance-critical sections to ensure consistent results across different compilers.

Developer Experience in Design

Improving the developer experience fosters a more maintainable and collaborative codebase.

Name Things Effectively

Strive for accurate nouns and verbs. Great names clearly convey what something is or does, creating an intuitive model. They demonstrate an understanding of the domain. Invest time in finding good names where nouns and verbs harmonize, making the whole greater than the sum of its parts.

  • Clear and Consistent Naming: Use descriptive and meaningful names for variables, functions, and files. Effective naming improves code readability and helps others understand each component's purpose. Adhere to a consistent style, such as snake_case, throughout the codebase.
  • Avoid Abbreviations: Use full words in names unless the abbreviation is widely accepted and unambiguous (e.g., ID, URL). Abbreviations can be confusing and make it harder for others, especially new contributors, to comprehend the code.
  • Include Units or Qualifiers in Names: Append units or qualifiers to variable names, ordering them by descending significance (e.g., latency_ms_max instead of max_latency_ms). This clarifies meaning, prevents confusion, and ensures related variables, like latency_ms_min, align logically and group together.
  • Document the 'Why': Use comments to explain the rationale behind decisions, not just what the code does. Understanding the intent helps others maintain and extend the code correctly. Provide context for complex algorithms, unconventional approaches, or key constraints.
  • Use Proper Comment Style: Write comments as complete sentences with correct punctuation and grammar. Clear, professional comments improve readability and demonstrate attention to detail, contributing to a cleaner, more maintainable codebase.

Organize Code Effectively

Well-organized code is easy to navigate, maintain, and extend. A logical structure reduces cognitive load, allowing developers to focus on problem-solving rather than deciphering code. Group related elements and simplify interfaces to keep the codebase clean, scalable, and manageable as complexity increases.

  • Organize Code Logically: Structure your code logically. Group related functions and classes together. Order code naturally, placing high-level abstractions before low-level details. Logical organization makes code easier to navigate and understand.
  • Simplify Function Signatures: Keep function interfaces simple. Limit parameters and prefer returning simple types. Simple interfaces reduce cognitive load, making functions easier to understand and use correctly.
  • Construct Objects In-Place: Initialize large structures or objects directly where they are declared. In-place construction avoids unnecessary copying or moving of data, improving performance and reducing the potential for lifecycle errors.
  • Minimize Variable Scope: Declare variables close to their usage and within the smallest necessary scope. This reduces the risk of misuse and makes code easier to read and maintain.

Ensure Consistency

Maintaining consistency in your code helps reduce errors and establishes a stable foundation for the rest of the system.

  • Avoid Duplicates and Aliases: Prevent inconsistencies by avoiding duplicated variables or unnecessary aliases. When two variables represent the same data, there's a higher chance they will fall out of sync. Use references or pointers to maintain a single source of truth.
  • Pass Large Objects by Reference: If a function's argument is larger than 16 bytes, pass it as a reference instead of by value to prevent unnecessary copying. This can help catch bugs early where unintended copies might occur.
  • Minimize Dimensionality: Keep function signatures and return types simple to reduce the number of cases a developer has to handle. For example, prefer void over bool, and bool over u64, when it suits the function's purpose.
  • Handle Buffer Allocation Cleanly: When working with buffers, allocate them close to where they are used and ensure all corresponding cleanup happens within the same logical block. Group resource allocation and deallocation with clear newlines to make leaks easier to identify.

Avoid Off-by-One Errors

Off-by-one errors frequently arise from casual interactions between an index, a count, or a size. Treat these as distinct types and apply clear rules when converting between them.

  • Indexes, Counts, and Sizes: Indexes are 0-based, counts are 1-based, and sizes represent total memory usage. When converting between them, add or multiply accordingly. Use meaningful names with units or qualifiers to avoid confusion.
  • Handle Division Intentionally: When performing division, explicitly state how rounding should be handled in edge cases. Use functions or operators designed for exact division, floor division, or ceiling division. This eliminates ambiguity and ensures the result behaves as expected.

Code Consistency and Tooling

Consistency in code style and tools improves readability, reduces mental load, and facilitates collaborative work.

  • Maintain Consistent Indentation: Use a uniform indentation style across the codebase. For example, employing 4 spaces for indentation provides better visual clarity, especially in complex structures.
  • Limit Line Lengths: Keep lines within a reasonable length (e.g., 100 characters) to ensure readability. This prevents horizontal scrolling and helps maintain an accessible code layout.
  • Use Clear Code Blocks: Structure code clearly by separating blocks (e.g., control structures, loops, function definitions) to make it easy to follow. Avoid placing multiple statements on a single line, even if permitted. Consistent block structures prevent subtle logic errors and make code easier to maintain.
  • Minimize External Dependencies: Reducing external dependencies simplifies the build process and enhances security management. Fewer dependencies lower the risk of supply chain attacks, minimize performance issues, and accelerate installation.
  • Standardize Tooling: Using a small, standardized set of tools simplifies the development environment and reduces accidental complexity. Choose cross-platform tools where possible to avoid platform-specific issues and improve portability across systems.

Addendum

Zero Technical Debt

While Tiger Style primarily emphasizes safety, performance, and developer experience, these principles are underpinned by a fundamental commitment to zero technical debt.

A zero technical debt policy is crucial for maintaining a healthy codebase and ensuring long-term productivity. Proactively addressing potential issues and building robust solutions from the outset helps prevent the accumulation of debt that would hinder future development.

  • Do It Right the First Time: Invest the necessary time to design and implement solutions correctly from the beginning. Rushed features inevitably lead to technical debt requiring costly refactoring later.
  • Be Proactive in Problem-Solving: Anticipate potential issues and resolve them before they escalate. Early detection saves time and resources, preventing performance bottlenecks and architectural flaws.
  • Build Momentum: Delivering solid, reliable code fosters confidence and enables faster development cycles. High-quality work supports innovation and reduces the need for future rewrites.

Avoiding technical debt ensures that progress is genuine progress—solid, reliable, and built to last.

Performance Estimation with Napkin Math

Performance considerations should be integrated early in the design phase, and "napkin math" is an invaluable tool for this.

Napkin math involves simple calculations and rounded numbers to quickly estimate system performance and resource requirements.

  • Quick Insights: Gain a rapid understanding of system behavior without extensive analysis.
  • Early Decisions: Identify potential bottlenecks early in the design process.
  • Sanity Checks: Verify the feasibility of an idea before committing to its implementation.

For example, when designing a system to store logs, you can estimate storage costs as follows:

  1. Estimate Log Volume:

    • Assume 1,000 requests per second (RPS).
    • Each log entry is approximately 1 KB.
  2. Calculate Daily Log Volume:

    • 1,000 RPS * 86,400 seconds/day * 1 KB ≈ 86,400,000 KB/day ≈ 86.4 GB/day.
  3. Estimate Monthly Storage:

    • 86.4 GB/day * 30 days ≈ 2,592 GB/month.
  4. Estimate Cost (using $0.02 per GB for blob storage):

    • 2,592 GB * $0.02/GB ≈ $51 per month.

This provides a rough estimate of monthly storage costs, helping to validate your logging plan. The goal is to get within an order of magnitude (10x) of the correct answer.