Skip to content

Mauitron/The-River

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

11 Commits
 
 
 
 
 
 
 
 

Repository files navigation

The River: A Better Way to Code

By Maui_The_Magnificent

Introduction

Before we start, I would like to mention that there is a condensed set of practical guidelines in the repo, feel free to read that first as it might help with understanding the rest of the essay.

Imagine standing beside a river. You can see exactly where the water comes from, how it flows around obstacles, where it joins other streams, and where it ultimately leads. The entire system is visible and comprehensible at a glance.

Now imagine taking that same water, breaking it into countless small bottles, and distributing them through a complex network of warehouses and delivery routes. What are the costs and benefits of keeping the water as a river versus bottling it up? Which system do you think is better, more intuitive? These are the questions I want you to reflect on as you continue to read.

The metaphor, though simple, captures the essence of how we often approach software development today, and why we might need to reconsider our fundamental assumptions about code organization. Modern programming practices encourage us to break our code into small, reusable pieces, essentially bottling our river of logic and creating an intricate distribution network. But in doing so, are we actually making our systems more comprehensible, or are we just trading one form of complexity for another?

The River tells us that Instead of fighting our natural thought processes, we should try to reduce cognitive load by embracing them. Code should flow like water, in continuous, visible paths from source to destination. Each function should be allowed to handle its complete responsibility, whether that takes 20 lines or 1000. The key is not the size but the clarity and integrity of the flow.

Just as a river naturally finds the most efficient path through its environment, our code should do the same, it should be allowed to follow its natural course. This doesn't mean writing spaghetti code, a river has structure and follows natural laws. But it does mean letting go of arbitrary rules about size and fragmentation that don't serve actual understanding.

The aim of this essay is not to convert you into a new dogma of 'best practices' but to highlight the problematic nature of what we are being tought, and to encurage you to reflect on the troubles of software developmen today, and to suggest an alternative set of guide-lines.

The Lesson of Flow

When solving problems, we tend to think in flows, in complete processes that have a beginning, middle, and end. Our minds are most optimized for understanding continuous narratives, not for piecing together meaning from fragments. Yet our current programming practices often force us to break our natural problem-solving flow into small, supposedly reusable pieces.

Think about how you would explain a complex process to someone. You wouldn't start by fracturing it into tiny, disjointed abstract chunks. You would tell it as a complete story, a continuous narrative that follows the natural flow of the process. If a particular point needs elaboration, you can spend as much time as needed explaining it, knowing each moment is intrinsically tied to the last. This is how our minds work best, and perhaps it's how our code should work too.

The Lesson of Cost

Every time we divide a natural process into separate functions, we're not just attempting to organizing code or reduce complexity, we're creating cognitive overhead. Each function boundary becomes a mental checkpoint where developers must pause, switch context, and reconstruct the overall flow. It's similar to having to stop at every warehouse in our bottled water distribution network to understand how the water gets from source to destination.

This fragmentation carries real costs:

  • Developers must maintain a complex mental map of how all the pieces fit together
  • Understanding the complete flow requires mentally reassembling it from fragments
  • Each abstraction adds complexity that potentially compounds throughout the system
  • The original developer's thought process and reasoning become obscured
  • Debugging becomes a process of tracing through multiple layers of abstraction

Bridging Philosophy and Pragmatism

The concept of code as a river, a continuous, comprehensible flow might sound compelling in theory, but how do we actually apply it in the real world of deadlines, legacy systems, and complex business requirements? The answer lies in the Linear Function Principle (LFP), a pragmatic approach to structuring code that embodies the river philosophy while remaining grounded in the realities of software development.

But before we delve deeper, it's important to understand that a program is rarely a single river, it's a watershed, a network of distinct rivers each handling their own complete flow. Each function should aim to be its own river, flowing from source to destination, with its own integrity and purpose. These rivers might connect at well-defined points, like tributaries joining a larger stream, but each maintains its own independant flow within its boundaries.

This might make it sound like a stance against abstractions, and in some ways it is, but when we talk about abstractions in the LFP, we're talking about local patterns within each individual river, not about as an attempt to serve or connect different rivers together. This is a crucial distinction. While our program might have many rivers, one for file operations, another for user interface handling, yet another for data processing, each should maintain its own clear, uninterrupted flow

The Linear Function Principle

The Linear Function Principle aims to provide a pragmatic framework for applying river-like structures in real-world software development. It states that functions should handle complete responsibilities in a linear, visible flow, regardless of their size.

In practice, this often means having longer functions than traditional guidelines suggest. But the principle asserts that this is not only acceptable, but often preferable, as long as those functions maintain a clear, linear flow that's easy to follow.

The key is the idea of "complete responsibility." Instead of breaking a task into many small, abstract pieces, it advocatews for reducing cognitive load by handling the entire task in one coherent flow. This makes the entire process visible and understandable in one place.

Consider a typical Rust web request handler:

async fn handle_request(req: Request<Body>) -> Result<Response<Body>, Infallible> {
    // Parse the request
    let (parts, body) = req.into_parts();
    let bytes = hyper::body::to_bytes(body).await?;
    let json: Value = serde_json::from_slice(&bytes)?;

    // Validate the parameters
    let user_id = json["userId"].as_str().ok_or("Missing userId")?;
    let item_id = json["itemId"].as_str().ok_or("Missing itemId")?;

    // Query the database
    let user = db::get_user(user_id).await?;
    let item = db::get_item(item_id).await?;
    if user.balance < item.price {
        return Ok(Response::builder()
            .status(StatusCode::BAD_REQUEST)
            .body("Insufficient funds".into())
            .unwrap());
    }

    // Update the database
    db::update_user_balance(user_id, user.balance - item.price).await?;
    db::add_item_to_user(user_id, item_id).await?;

    // Construct the response
    let response_body = json!({
        "message": "Purchase successful",
        "balance": user.balance - item.price
    });

    Ok(Response::builder()
        .status(StatusCode::OK)
        .header("Content-Type", "application/json")
        .body(serde_json::to_string(&response_body)?.into())
        .unwrap())
}

Under the Linear Function Principle, the entire request handling process is a single function. This function encapsulates the complete flow of receiving a request and generating a response, making the entire process visible and understandable in one place.

One big advantages of the Linear Function Principle is the visibility it provides. When complete processes are encapsulated in single functions, it becomes much easier to debug, and maintain them.

Consider the process of debugging. With traditional small functions, you often have to step through many different functions, mentally keeping track of the context and state at each step. With linear functions, you can see the entire flow in one place, making it much easier to identify and fix issues. If one feature isn't working as intended, you will know exactly where to look, because there is only one place the error could occure.

The Lesson of Repetition in Rivers

Stand at different points along a river's course, and you'll notice something that might initially seem wasteful: the river appears to solve similar problems in similar ways, over and over. When the river meets a boulder, it forms an eddy. When it encounters a steep decline, it creates rapids. When it flows into a valley, it meanders. These patterns repeat themselves countless times along the river's journey.

Yet no one would accuse the river of being inefficient or suggest that it should centralize its pattern-making into a single location. We intuitively understand that each instance of these patterns serves its specific local purpose, responding to and shaped by its immediate context. The eddy behind one boulder isn't "duplicating" the eddy behind another - each is its own response to its own local conditions.

This observation holds a profound lesson for how we think about repetition in our code. We've been taught to fear what we call "code duplication", to see any similarity between different parts of our codebase as a problem to be solved through abstraction. But perhaps we've been looking at it wrong.

When a search function needs to check permissions, and a document editing function needs to check permissions, are they really duplicating each other? Or are they like those eddies in the river, similar patterns emerging naturally in response to similar local conditions? Each permission check serves its own purpose, shaped by its own context, evolving according to its own needs.

The fear of duplication comes from a reasonable place: the desire to avoid inconsistency and reduce maintenance burden. If we have the same logic in multiple places, we worry that changes will need to be made everywhere, that implementations will drift apart, that bugs will be fixed in one place but not another. But this fear assumes that these similar pieces of code should stay in sync, that they're really the same thing repeated.

What if, instead, we recognized that code which looks similar might actually be different things serving different purposes? Just as each eddy in a river is its own entity, shaped by its own local conditions, perhaps each instance of what we call "duplicated" code is actually its own thing, rightfully independent of its similar-looking cousins.

Consider how this changes our approach to code organization. Instead of immediately reaching for abstraction when we see similar patterns, we might first ask: Are these patterns truly serving the same purpose? Do they need to evolve together? Or are they like those river eddies, similar in form but independent in purpose? It is important to highlight that even if the code will stay the same forever, in the river duplicated code not only serves its context and help reduce cognitive load, it also helps isolate the river from its brothers and sisters.

This perspective frees us from the 'tyranny' of premature abstraction. Isolation in this situation means that when search needs permission checking, it can implement exactly the permission checking it needs, optimized for its specific requirements. When document editing needs permission checking, it can do the same. If their needs diverge over time, they're free to evolve independently. If their needs stay similar, they can continue to use similar patterns. The key is that this similarity is allowed to emerge naturally rather than being forced through abstraction.

The cost of this approach isn't in maintaining "duplicate" code, it's actually lower than the cost of maintaining unnecessary abstractions. Each feature remains clear and self-contained, its logic visible and comprehensible within its own flow. Changes can be made confidently because their scope is naturally limited. New developers can understand and start work on each feature in isolation, without having to grasp a complex web of shared abstractions.

In the end, perhaps we need to stop seeing every repeated pattern as duplication to be eliminated, and start seeing it as nature does, as similar solutions arising naturally to meet similar needs in different contexts. Our code, like rivers, should be free to develop the patterns it needs where it needs them, guided by local conditions rather than global abstractions.

The Lesson of Isolation and Fault Tolerance

Another key benefits of the river and allowing for "duplicated" code is fault isolation. By keeping each feature flow separate and self-contained, problems become easier to locate and fix.

Think about how a river system handles a blockage or contamination. If a tree falls and blocks a small stream, it doesn't bring down the entire river system. The impact is localized to that specific stream. Other streams and the main river continue to flow unimpeded.

Similarly, in a well-structured river-based system, a bug or failure in one feature flow shouldn't propagate to the entire system. The problem can be isolated, diagnosed, and fixed within the context of that specific flow.

This isolation is achieved through the principle of complete responsibility. By having each flow handle its own concerns end-to-end, including things like error handling and logging, the impact of any issues is naturally contained.

For example, consider an e-commerce system with separate flows for product browsing, cart management, and checkout. If there's a problem with the cart flow, it can be debugged and fixed without necessarily impacting the other flows. The browsing and checkout flows can continue to function while the cart issue is being resolved.

This is in contrast to a heavily abstracted system where functionality is spread across many interconnected components. In such a system, a failure in one component can easily cascade and cause unexpected issues across the entire system. Debugging becomes a game of whack-a-mole, trying to trace the failure through multiple layers of abstraction.

Of course, achieving perfect isolation is not always possible. There is likely to be some level of interdependency between different parts of a system. But the river approach aims to, if not remove, at least minimize unnecessary coupling and allow each flow to operate as independently as possible within the constraints of the system.

The Lesson of Constant Complexity Scaling

Rivers demonstrate a remarkable scaling pattern that we can learn from when designing software systems. As rivers grow from tiny streams to massive deltas, they maintain their fundamental nature as a continuous flow of water from source to destination. Despite the significant increase in size and capacity, rivers don't become fundamentally more complex or fragmented.

This contrasts with our typical approach to scaling software systems, where we often assume that growth necessitates increased complexity, abstraction, and fragmentation. However, taking a lesson from rivers, we can aim for scaling without adding unnecessary complexity.

We can structure our software systems like river systems, where each major feature or capability is handled by its own "river", a coherent, continuous flow of logic from start to end. As the system grows, new features are added as new rivers that join the system, increasing capability without adding complexity to existing flows.

As mentioned, because a river maintains its own internal structure and flow, independent of the others. Changes to one river don't require changes to the entire system. Rivers naturally divide and specialize based on the terrain they flow through. For example, authentication flows differ from search flows, which differ from checkout flows, but each follows the same principle of continuous, coherent progression.

In this model, scaling is achieved by adding more rivers rather than making existing rivers more complex.

The river-like approach to scaling provides several key benefits. Firstly, it maintains what we might call constant complexity, where each feature maintains a constant level of complexity regardless of the size of the overall system. Understanding one flow, such as a login flow, doesn't become harder just because the system has grown to include a complex search feature.

Secondly, it enables independent development. Teams can work on different feature rivers independently, without needing to coordinate on a monolithic central architecture. Each team can optimize their flow for their specific needs.

Thirdly, it allows for graceful growth. Adding new features doesn't require rearchitecting the entire system. New flows can be added incrementally, and old flows can be modified or replaced without affecting the others.

Fourthly, it localizes change. When requirements change, the impact of those changes is localized to specific feature flows. You don't have to worry about a small change rippling out across the entire system.

Lastly, it enables scalable understanding. New developers can get up to speed quickly by focusing on understanding one flow at a time. They don't need to grasp the entire system before they can start contributing.

The Lesson of Context-Rich Exceptions

In river-based code, exceptions and error messages can be much richer in context because all the relevant information is available within the flow.

Consider the difference:

# In fragmented code
def parse_user_id(user_id_str):
    try:
        return int(user_id_str)
    except ValueError:
        raise InvalidUserIdException("Invalid user ID")

# In river-based code
def load_user_profile(user_id_str):
    try:
        user_id = int(user_id_str)
    except ValueError:
        raise InvalidUserIdException(f"Invalid user ID: {user_id_str}")
    
    user = get_user_by_id(user_id)
    if not user:
        raise UserNotFoundException(f"No user found with ID: {user_id}")
    
    return user

In the fragmented example, the exception message is generic because the parse_user_id function doesn't have any context about how the ID string will be used. In the river-based example, the exception can include the actual invalid value, and there's a second, more specific exception if no user is found for the parsed ID.

Flow-Aware Debugging Tools

As river-based code becomes more common, our debugging tools will need to evolve to take advantage of the flow-based structure.

Imagine a debugger that understands the concept of flows:

  • It could provide flow-based breakpoints, allowing you to pause execution at the beginning or end of a specific flow
  • It could visualize the state changes within a flow, showing how data is transformed step by step
  • It could provide flow-specific logging, automatically capturing the inputs and outputs of each major flow
  • It could even detect and highlight deviations from expected flow patterns, drawing attention to potential issues.

Testing

Linear functions can actually be easier to test because you're testing complete processes rather than trying to test fragments in isolation. Integration tests become more straightforward because the integration points are more visible.

Handling Cross-Cutting Concerns

A common question about about the river is how it handles cross-cutting concerns like logging, monitoring, and security. After all, these are things that need to be consistent across the entire system right?

But what if we think of these concerns as part of the environment that the rivers flow through instead, rather than as separate rivers themselves?

What are the reasons against each river being allowed to handle its own logging in the way that best fits its specific needs? Is there a reason why logging statements couldn't become part of the local flow itself, rather than an external concern imposed from the outside? If we think about it in this way, the logging would then fit naturally into its context and narrative.

fn process_order(order: Order) -> Result<(), String> {
    info!("Processing order #{}", order.id);
    
    // Validate the order
    if !order.is_valid() {
        error!("Invalid order #{}", order.id);
        return Err(format!("Order {} is invalid", order.id));
    }
    
    // Process the payment
    if !process_payment(&order)? {
        error!("Payment failed for order #{}", order.id);
        return Err(format!("Payment failed for order {}", order.id));
    }
    
    // Ship the order
    ship_order(&order)?;
    info!("Order #{} processed successfully", order.id);
    Ok(())
}

Similarly, security checks and permissions can be handled the same way, within the flow that requires them. The authentication and authorization would then become part of the narrative of the user's journey through that specific feature.

fn view_account(user: &User, account_id: i64) -> Result<Account, String> {
    // Check if the user is authenticated
    if !user.is_authenticated() {
        return Err("User must be logged in to view account details".to_string());
    }
    
    // Get the account
    let account = get_account(account_id)?;
    
    // Check if the user has permission to view this account
    if !user.can_view(&account) {
        return Err(format!("User {} does not have permission to view account {}", user.id, account.id));
    }
    
    // Return the account details
    Ok(account)
}

The Inefficiency of Fragmentation

Traditional approaches to code organization tend to lead to fragmentation, breaking logic into many small, potentially reusable pieces. While this can make individual pieces of code, at a glance, appear simpler and cleaner, it often introduces inefficiencies in the overall flow of the program.

Consider a typical data processing pipeline:

def fetch_data():
    # Fetch data from an API

def parse_data(raw_data):
    # Parse the raw data into a usable format

def filter_data(parsed_data):
    # Filter the parsed data based on some criteria

def aggregate_data(filtered_data):
    # Aggregate the filtered data

def store_data(aggregated_data):
    # Store the aggregated data in a database

def process_data():
    raw_data = fetch_data()
    parsed_data = parse_data(raw_data)
    filtered_data = filter_data(parsed_data)
    aggregated_data = aggregate_data(filtered_data)
    store_data(aggregated_data)

Each step in the pipeline above is its own function, which seems like an attractive and modular approach. But consider, could there be performance implications?

  • Each function call has an overhead in terms of stack manipulation and function prologue/epilogue
  • Data is passed between functions, requiring serialization and deserialization
  • The flow of data is obscured, making it harder to optimize the overall pipeline

Now consider the same pipeline written as a single, flow-based function:

def process_data():
    data = fetch_data()
    
    # Parse the data
    parsed_data = []
    for item in data:
        parsed_item = parse_item(item)
        parsed_data.append(parsed_item)
    
    # Filter the data
    filtered_data = []
    for item in parsed_data:
        if should_include(item):
            filtered_data.append(item)
    
    # Aggregate the data
    aggregated_data = {}
    for item in filtered_data:
        key = get_aggregation_key(item)
        if key not in aggregated_data:
            aggregated_data[key] = []
        aggregated_data[key].append(item)
    
    # Store the data
    for key, items in aggregated_data.items():
        store_items(key, items)

While this version might be longer, it has several performance advantages:

  • There's no overhead from function calls
  • Data flows locally and directly from one step to the next, without serialization
  • The entire flow is visible, making it easier to identify optimization opportunities

The Efficiency of Flow

The principle at play here is one of flow efficiency. In a fragmented system, data must constantly stop and start as it moves between disconnected pieces. In a flow-based system, data moves smoothly and continuously from start to end.

This principle applies at multiple levels. At the level of functions, flow-based code minimizes the overhead of function calls and data passing. At the level of systems, flow-based architectures minimize the latency and overhead of communication between services.

If you consider a microservices architecture where each service is responsible for a single step in a data processing pipeline. Each service might be optimally efficient in isolation, but the overall system is slowed down by the constant back-and-forth communication between services.

The efficiency gain of these things might be small, but these traditional inefficiencies tend to compound, and if you look at it as cost over time, the differences will become much larger.

The Role of Data

In flow-based systems, data is the lifeblood that carries state and intent from the beginning of a process to its end. Optimizing the flow of data is therefore crucial for performance.

This could mean:

  • Minimizing data transformations and conversions
  • Keeping data in memory where possible, rather than constantly serializing and deserializing.
  • Identify opportunities to short circuit and direct flows efficiently
  • Read the story and look for ways to infer either data or computation.

The Visibility of Problems

In traditional, modulized code, problems often manifest far from their source. A null pointer exception in one function might be caused by an improper initialization in a completely different part of the codebase. Tracing the issue requires mentally reconstructing the flow of data and control across many disconnected pieces.

In river-based code, problems tend to manifest within the flow where they originate. If there's an issue in the user authentication flow, it will surface within that flow's logic. The problemw and its context are inherently visible, not scattered across distant functions.

Consider a typical user registration flow:

def register_user(registration_data):
    # Validate the registration data
    if not is_valid_email(registration_data['email']):
        raise InvalidEmailException("The provided email is not valid")
    
    if not is_strong_password(registration_data['password']):
        raise WeakPasswordException("The provided password is too weak")
    
    # Check if the user already exists
    if user_exists(registration_data['email']):
        raise UserExistsException("A user with this email already exists")
    
    # Create the new user
    new_user = create_user(registration_data)
    
    # Send a welcome email
    send_welcome_email(new_user)
    
    return new_user

If an exception occurs anywhere in this flow, it's immediately clear where and why it happened. The context is right there in the flow, not hidden behind layers of abstraction.

The Lesson of Abstractions

In the world of software development, we have been taught to seek out common patterns and abstract them into reusable components. The principle of "Don't Repeat Yourself" (DRY) has become so ingrained in our minds that we often apply it without much thought. However, this approach often lead us to create abstractions that prioritize theoretical cleanliness over practical understanding.

When we abstract, we often encounter several issues. We end up creating generic solutions that don't quite fit any specific case perfectly, leading us to build complex parameter systems to handle different use cases. We find ourselves spending more time managing our abstractions than actually solving the problems at hand. Moreover, developers are forced to understand these complex abstractions before they can even grasp the simple features they are meant to support.

The root of this issue lies in how abstractions are thought to be used, which often focus more on avoiding duplication than on creating clarity and comprehensibility. In our pursuit of DRY principles, we sometimes lose sight of the actual goal: writing code that is performant, easy to understand, modify, and maintain.

As mentioned before, The River doesn't prohibit abstractions. Rather, it aims to provide a different set of guideline for when and how to abstract. Instead of abstracting for reusability or to meet arbitrary size limits, it advocates abstracting only when it genuinely improves understanding and/or maintainability.

In the context of code, this translates to creating abstractions that serve the immediate feature rather than the entire system. The focus shifts from global reuse to local understanding, keeping the context and purpose of abstractions visible and clear.

When abstractions stay close to their usage, they become easier to understand because their context is preserved. A function that exists within the flow of a feature carries its meaning more clearly than one that has been abstracted away into a utility library.

By keeping abstractions local and context-aware, we can create code that is more intuitive and easier to comprehend. Developers can focus on understanding the specific feature they are working on without getting lost in a maze of abstract utility functions and generic interfaces.

To be clear, this approach doesn't mean completely abandoning the concept of reusable code or ignoring the benefits of well-designed abstractions. Rather, it suggests a more balanced and pragmatic approach, where abstractions are created when they truly enhance clarity and maintainability, rather than solely for the sake of avoiding duplication.

In conclusion, rethinking our relationship with global abstractions and embracing the concept of local abstractions that follow the natural flow of code can lead to more understandable, maintainable, and evolvable software systems. By focusing on context and clarity, we can create abstractions that serve the needs of both the developers working on the code and the users interacting with the resulting features.

Consider our help screen example:

pub fn display_help_screen(...) {
    // Local abstraction that knows exactly what it needs to do
    let get_key_for_action = |action: &Action| -> String {
        config
            .keybindings
            .as_ref()
            .and_then(|kb| {
                kb.iter().find_map(|(k, v)| {
                    if v == action {
                        Some(key_event_to_string(k))
                    } else {
                        None
                    }
                })
            })
            .unwrap_or_else(|| "Unbound".to_string())
    };

    // The abstraction lives where it's used, carrying its context with it
    let navigation_items = [
        (get_key_for_action(&Action::MoveUp), "Move up"),
        (get_key_for_action(&Action::MoveDown), "Move down"),
        // ...
    ];
}

Here's another example in the context of a web application:

function renderProductPage(product) {
  // Local function to render product details
  function renderDetails(product) {
    return `
      <div class="product-details">
        <h2>${product.name}</h2>
        <p>${product.description}</p>
        <p>Price: ${product.price}</p>
      </div>
    `;
  }
  
  // Local function to render related products
  function renderRelated(relatedProducts) {
    return `
      <div class="related-products">
        <h3>Related Products</h3>
        ${relatedProducts.map(renderProductThumbnail).join('')}
      </div>
    `;
  }

  return `
    <div class="product-page">
      ${renderDetails(product)}
      ${renderRelated(product.relatedProducts)}
    </div>
  `;
}

Facilitating Effective Code Reviews

Another major benefit of local abstractions is how they facilitate more effective code reviews. When a change is being reviewed, having the relevant abstractions right there in the same context makes it much easier for the reviewer to understand the full impact of the change.

Consider a code review for a change to our data processing example:

def process_data(raw_data):
    def clean_entry(entry):
        return entry.strip().lower()
    
    cleaned_data = [clean_entry(entry) for entry in raw_data]
    
    def is_valid_entry(entry):
        return entry != "" and entry != "n/a"
    
    valid_data = [entry for entry in cleaned_data if is_valid_entry(entry)]
    
    def transform_entry(entry):
-       return entry.split(",")
+       return [int(x) for x in entry.split(",")]
    
    transformed_data = [transform_entry(entry) for entry in valid_data]
    
    return transformed_data

The reviewer can immediately see that the transform_entry function was changed to convert the split values to integers. They can also see how this change fits into the overall data processing pipeline. They don't have to go hunting through different files to understand the context and impact of this change.

When to Create Abstractions

Abstractions don't need to be created upfront and really, they rarely should be. Just as rivers develop their patterns over time based on the actual flow of water, our abstractions should be allowed to emerge and evolve based on real usage patterns.

When initially writing a piece of functionality, it's often best to start with inline code, even if there is potential for abstraction. As the feature is developed and the code is put into practice, natural patterns will surface. These patterns indicate areas where abstraction can be applied.

This is a more evolutionary approach to abstraction that allows for a more organic and pragmatic development process. It enables abstractions to be shaped by the actual needs and requirements of the code, rather than being imposed prematurely based on theoretical assumptions.

As touched on before, by allowing natural boundaries to exist between different parts of the codebase, we can create abstractions that are more focused, understandable, and maintainable because they are tied to their specific contexts.

The decision to create abstractions should be guided by several key factors.

Firstly, abstractions should simplify the understanding of the current feature. They should make the code easier to comprehend and reason about, rather than adding unnecessary complexity.

Secondly, abstractions should capture genuinely common patterns within a specific context. They should encapsulate recurring logic or functionality that is relevant to the particular feature or module being developed.

Thirdly, abstractions should reduce cognitive load rather than increasing it. They should help developers focus on the essential aspects of the code without getting bogged down in excessive indirection or convoluted abstractions.

Lastly, and perhaps most importantly, abstractions should preserve the natural flow of the code rather than fragmenting it. They should work in harmony with the code's inherent structure and logic, rather than disrupting or obscuring it.

It's crucial to emphasize this last point. Abstractions that make the code harder to follow or understand are likely not serving their intended purpose, regardless of how much duplication they eliminate.

I want to take a moment to mention that there is a case to be had over the usefulness of semantic information that abstactions can provide, this information can be highly valuable as it can be used to reduce things like computation and memory allocation.

But in most cases, if the decission to abstract code globally is made, it should be a conscious choice where the benefits are deemed to be far greater than the cost. Because each new global abstraction risks bottling up more and more of the river we are trying to perserve.

The Power of Storytelling

At its core, the River approach is about embracing the narrative nature of software development. It recognizes that code is not just a set of instructions for a computer, but a story that we tell about how a system works and what it does.

Each feature flow is a chapter in this story, with its own characters (data and state), plot (logic and behavior), and arc (start to finish). The goal is to make each chapter as self-contained and comprehensible as possible, so that anyone can pick it up and understand what's happening without needing to reference a dozen other chapters.

This is why the principle of complete responsibility is so important. It ensures that each chapter tells a complete story, rather than just a fragment that needs to be pieced together with other fragments scattered throughout the codebase.

It's also why duplication is not always the enemy in this approach. Sometimes, telling a clear and complete story requires repeating certain elements or patterns. As long as each repetition serves a clear purpose within its specific context, it can actually enhance rather than detract from the overall narrative.

Of course, this doesn't mean that we should ignore all opportunities for abstraction and reuse. There will always be cases where certain elements of the story truly are the same and can be effectively factored out. But the key is to let these abstractions emerge naturally from the stories we're telling, rather than imposing them upfront based on assumptions about what might be reused in the future.

In the end, the real power of the River of Code approach is not in any specific technical principle or pattern, but in the mindset shift it represents. It invites us to think about software development not just as a process of writing instructions, but as a process of telling stories.

By embracing this narrative mindset, we can create code that not only works, but that also communicates, that explains itself, that guides the reader through a clear and coherent journey. We can create systems that are not just collections of functions and classes, but living, flowing narratives about what the system is, what it does, and why it matters.

And that, ultimately, is the real promise of the River of Code. Not just cleaner or more efficient code, but code that tells a story, code that has meaning and purpose, code that reflects the real complexity and richness of the problems we're trying to solve.

So as you write your next feature or debug your next issue, ask yourself: What story am I trying to tell here? How can I make this story as clear, as complete, and as compelling as possible? And what are the steps I need to take to make it flow naturally, like a river, from beginning to end?

The answers to these questions may not always be easy or obvious. But by asking them, and by striving to create code that is clear and meaningful, we can tap into a deeper level of craftsmanship and artistry in our work. We can create software that not only works, but that also communicates, that inspires, and that is honest.

And in the end, isn't that what this whole coding thing is about? Not just solving problems, but telling the stories that we want to tell.

So please, let your code tell its story. And let that story be a good one.

Practical Principles for River-Like Systems

And lastly, a set of guiding principles for building systems in the way that this philosophy purposes:

  1. Aim for Coherent, End-to-End Flows: Each major feature should be handled by a single, isolated flow of logic that's understandable from start to finish. Avoid breaking flows up into many small, abstract pieces. Because this allows for easier to maintain code

  2. Let the Domain Guide Structure: The structure of your system should mirror the structure of the problem domain. If your e-commerce system naturally divides into catalog, cart, and checkout features, your code should reflect that same division.

  3. Keep Flows Independent: As much as possible, feature flows should be independent of each other. They can interact at well-defined points (like tributaries joining a river), but changes to one flow shouldn't require changes to others. This allows for fault isolation, constant complexity scaling and easy onboarding of new talant.

  4. Allow for Local Variation: Different feature flows will often need to handle similar subproblems (like validation, error handling, etc.) in different ways. That's okay! Local optimization beats global consistency.

  5. Grow Organically: Don't try to anticipate every possible future feature and abstraction upfront. Let your system grow and evolve naturally as new requirements come in. Refactor to maintain clear flow, but don't overengineer.

Email Github

If you found this essay interesting or valuable, please consider buying me a Coffee

About

A critique and alternative to 'best practices'

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published