Your Framework Should Not Own Your Business Logic

We have a habit of calling things non-functional requirements when we don’t want to think too hard about them.

Transactions. Retries. Rollbacks. These are often delegated to frameworks through annotations, configuration, or other runtime mechanisms. We treat them as infrastructure concerns, separate from the application itself.

I think this is a mistake.

Not because frameworks are bad, or because annotations are inherently wrong. The problem is that, by using them, we can unknowingly delegate decisions that belong to our application to a framework that has no business making them.

A transaction is not just an infrastructure concern

Consider a Spring application:

@Transactional
public void processOrder(Order order) {
    inventory.reserve(order);
    payment.charge(order);
    shipping.schedule(order);
}

This looks reasonable. The method performs a business operation, and Spring takes care of the transaction.

But what does taking care of the transaction actually mean?

  • Which operations must succeed or fail together for the order to remain consistent?
  • What happens if inventory is reserved but the payment fails?
  • Can we roll back the payment? Can we release the inventory?
  • What happens if shipping has already been scheduled when a later operation fails?
  • Which failures should trigger a rollback, and which should not?

These are not merely database questions. They are business questions.

A transaction defines a consistency boundary. It determines which state changes must be treated as one logical operation and what happens when that operation cannot be completed.

Putting @Transactional on a method does not answer these questions. It delegates the execution of a transaction to Spring, while the actual consistency requirements remain the application’s responsibility.

Worse, the annotation can make us believe that we have handled consistency when we have only configured one particular mechanism for it.

A database rollback, for example, cannot undo a payment processed by an external provider. The framework cannot infer the correct business response to that situation.

The application must own the decision. The framework should only help implement it.

The same problem applies to retries

Consider another familiar annotation:

@Retryable(maxAttempts = 3)
public void processPayment(Order order) {
    paymentGateway.charge(order);
}

Three attempts. Sounds reasonable.

But should we retry every failure? What if the payment provider processed the charge but the response was lost? Could another attempt charge the customer twice? Is the operation idempotent? Should a timeout be treated differently from a declined payment?

The correct retry policy depends on the operation’s semantics, its failure modes, and the guarantees provided by the external system.

Those are application-level concerns.

A retry annotation can execute a policy, but it cannot determine whether that policy is correct for the business operation. Even when the annotation is configured carefully, the retry mechanism can obscure the actual control flow and make it harder to reason about the application’s behavior.

This does not mean that retry mechanisms should be implemented from scratch. It means that the decision to retry, the conditions under which retries are allowed, and the consequences of repeated execution should be explicit and testable application behavior.

The framework may provide the mechanism. It should not silently become the owner of the policy.

Why do we call these non-functional requirements?

I suspect the terminology contributes to the problem.

We call performance, reliability, consistency, transaction management, and retry behavior non-functional because they do not describe the primary business outcome of an operation.

But this classification can be misleading.

Some concerns that are commonly grouped under non-functional requirements have direct consequences for functional correctness.

A payment processed twice is not merely a reliability issue. An order whose inventory has been reserved but whose payment has failed is not merely a transaction-management issue. A retry that repeats an irreversible operation is not merely an infrastructure issue.

These are failures of application behavior.

Of course, not every non-functional requirement is business logic. HTTP transport, database drivers, memory consumption, and deployment configuration are genuine infrastructure or operational concerns. But we should not use the label non-functional as an excuse to remove important decisions from the application code.

If a decision changes what the application does when something succeeds, fails, or is repeated, it deserves the same scrutiny as any other business rule.

Make the application testable without its framework

The application should be testable using plain Java tests, without starting Spring, a CDI container, or a database just to verify its decisions.

Consider a payment operation that should be retried only when a temporary failure occurs. Instead of delegating the entire policy to a @Retryable annotation, we can express it through a separate, reusable retry operation:

public static <T> Either<RuntimeException, T> retry(
        Supplier<T> operation,
        Class<? extends RuntimeException> retryOn,
        int maxAttempts) {

    RuntimeException lastError =
        new IllegalArgumentException("maxAttempts must be positive");

    for (int attempt = 0; maxAttempts > 0 && attempt < maxAttempts; attempt++) {
        Try<T> result = Try.of(operation::get);

        if (result.isSuccess()) {
            return Either.right(result.get());
        }

        Throwable cause = result.getCause();

        lastError = cause instanceof RuntimeException
            ? (RuntimeException) cause
            : new RuntimeException(cause);

        if (!retryOn.isInstance(lastError)) {
            break;
        }
    }

    return Either.left(lastError);
}

The business operation remains independent of the retry mechanism:

public Either<RuntimeException, PaymentResult> charge(Order order) {
    return retry(
        () -> paymentGateway.charge(order),
        TemporaryFailure.class,
        3
    );
}

Now we can test the retry policy independently, using a plain unit test and a fake payment gateway. We can verify that a temporary failure is retried, that success stops further attempts, and that the final failure is returned.

No Spring context. No proxies. No application server.

The example is deliberately simple; a real implementation must also consider idempotency, timeouts, backoff, and which failures are safe to retry. Those decisions depend on the operation and belong in its policy, not in a generic annotation applied without considering its semantics.

The point is that we should be able to test the application’s decisions independently of the mechanism that executes them.

Testing through the execution mechanism adds complexity on both sides: tests become harder to write and maintain, while the application inherits additional runtime behavior that can introduce overhead, hidden interactions, and bugs.

That complexity has a cost: development time, maintenance effort, and, ultimately, correctness. We should pay that cost only when the mechanism provides real value, not simply because our framework makes it convenient.

The same principle applies to transaction boundaries. We should be able to test which operations must succeed together and how failures are handled, while using integration tests to verify that the real database adapter correctly implements commits and rollbacks.

Does this mean we should eliminate frameworks?

Absolutely not.

An application does not run in a vacuum. It needs an environment, and that environment provides essential capabilities.

We are not going to write our own HTTP server just to receive user requests. We are not going to write a database driver. We do not need to implement dependency injection ourselves when a suitable container already exists.

Using Spring Boot, CDI, an application server, or another runtime is perfectly reasonable. So is using framework-specific adapters to connect the application to databases, message brokers, HTTP endpoints, and external services.

The distinction is where we draw the boundary.

I want the application to depend on abstractions that express the capabilities it needs, while the runtime supplies the implementations.

For example:

  • An HTTP adapter translates incoming requests into application calls.
  • A persistence adapter translates application operations into database queries.
  • A transaction adapter implements the transaction boundaries requested by the application.
  • A retry mechanism executes a retry policy that the application has explicitly defined.

The application should not need to know how an HTTP request is received or how a database connection is managed. But it should know which operations must be consistent, which failures are recoverable, and what should happen when an operation cannot be completed.

This is not about eliminating all framework dependencies from every line of code. It is about keeping framework-specific concerns at the edges, rather than allowing them to define the application’s behavior.

Framework independence is also about avoiding unnecessary complexity

There is another reason to care about this distinction: frameworks can introduce behavior that is not immediately visible in the code.

A method annotated with @Transactional may be intercepted by a proxy. Its behavior can depend on how the object was created, how the method was invoked, whether the invocation crossed the relevant proxy boundary, and how transaction propagation and rollback rules are configured.

Self-invocation, for example, can bypass proxy-based transaction interception in common Spring configurations. A method may look transactional in the source code while its actual execution does not behave as expected.

The issue is not that Spring is defective. These mechanisms have legitimate uses, and the framework documents their constraints.

The issue is that an implicit runtime mechanism introduces another layer of behavior that developers must understand and verify.

Sometimes that layer is justified. Sometimes a small, explicit piece of application code would be easier to reason about, easier to test, and less likely to conceal a mistake.

The more important a decision is to correctness, the less comfortable I am leaving it implicit.

Portability is a consequence, not the main goal

An application whose core behavior can be tested independently of its runtime is generally easier to move to another environment.

Perhaps today we use Spring Boot and tomorrow we need an application server. Perhaps we replace a database, change a message broker, or introduce a different external service.

If our application is tightly coupled to framework-specific annotations and runtime behavior, these changes can affect far more code than they should.

If the application expresses its requirements through ordinary code and clear interfaces, we can often replace the relevant adapters without rewriting the business logic.

But portability is not the primary goal.

I am not suggesting that every application should be designed to support five different frameworks or databases. That can become abstraction for abstraction’s sake.

The primary goal is to make application behavior explicit, understandable, and independently testable. Portability follows naturally from that discipline.

A practical rule

When deciding whether something belongs in the application or in a framework, I ask a simple question:

Can I verify that this behavior is correct using ordinary tests of the application itself, without starting its runtime?

If the answer is no, I investigate why.

Sometimes the behavior genuinely belongs to infrastructure, and an integration test is the appropriate way to verify it.

But sometimes the application has delegated a decision it should own to an annotation, a proxy, a container, or a framework default.

In that case, I try to bring the decision back into explicit application code and leave the framework responsible for providing the underlying mechanism.

This is not a ban on annotations. It is not a demand for hand-written infrastructure. And it is certainly not an argument for implementing everything ourselves.

It is a way of keeping responsibility where it belongs.

Your framework should help your application do its job. It should not quietly decide what that job is.

Choosing a Programming Language Is an Engineering Decision, Not a Matter of Taste

Performance, correctness, deployment, ecosystem, and maintenance: the objective criteria that matter more than personal preference.

Software engineers can spend an extraordinary amount of time debating programming languages. Rust versus Go. Java versus C++. TypeScript versus Elm. Functional programming versus object-oriented programming.

These discussions often revolve around personal preferences, isolated benchmarks, syntax, or the latest industry trends. Yet the most important question is rarely asked:

Which programming language minimizes the total cost and risk of building, deploying, and maintaining this particular system?

A programming language is a tool. Like any engineering tool, its value depends on the problem being solved, the constraints involved, and the people using it.

There are objective criteria for evaluating that value. Understanding them makes language selection a rational engineering decision rather than a matter of taste.

1. Runtime performance: measure the generated program, not the language’s reputation

The claim that C++ is fast, Java is slower, and Python is slow is a convenient oversimplification.

At the lowest level, computation comes down to machine instructions, register usage, memory access, cache locality, branching, and parallel execution. Programming languages are different ways of expressing computations, and their implementations translate those expressions into operations the hardware can execute.

For a given computational problem, different languages can produce machine code with comparable performance. The important distinction is not necessarily the performance they can achieve, but the effort required to achieve it.

Consider Java and C++.

A Java application running on a modern JVM can benefit from just-in-time compilation, method inlining, escape analysis, and other runtime optimizations. With an understanding of how the JIT compiler works, a developer can structure code so that the runtime produces highly optimized machine instructions.

In some workloads, the resulting performance can be comparable to an equivalent C++ implementation.

That does not mean the languages are universally equivalent in performance. Their memory models, runtime overhead, optimization opportunities, and constraints differ. It means that a language’s reputation is a poor substitute for measuring the actual system.

When evaluating performance, consider:

  • The computational workload and its algorithmic complexity.
  • The quality of the compiler, JIT compiler, or interpreter.
  • Memory layout, cache locality, and allocation behavior.
  • Vectorization, concurrency, and hardware acceleration.
  • Latency requirements, throughput, and memory consumption.

Benchmark representative workloads, not artificial examples that have little resemblance to production.

When the language really matters

Some constraints make particular language and runtime choices objectively unsuitable.

A small embedded device with limited RAM may not have room for a garbage collector and its associated runtime. A hard real-time system may not tolerate unpredictable pauses. A GPU kernel may require a specific programming model. A browser application must execute in an environment with defined compatibility constraints.

These are real engineering limitations, not stylistic preferences.

The right question is not which language is fastest in general, but which implementation can meet the system’s performance requirements within its resource constraints.

2. Correctness: how many bugs can you prevent before execution?

Performance gets attention because it is easy to compare in benchmarks. Correctness is often more important and considerably harder to quantify.

A defect discovered during development is generally cheaper to fix than one discovered in production. For small teams, this creates a particular challenge: the people writing the software may also be responsible for testing it, deploying it, supporting it, and fixing production incidents.

A language that catches more classes of mistakes at compile time can reduce the amount of verification work required elsewhere.

This is where languages such as Elm and Haskell become interesting.

Elm: making entire categories of mistakes harder to express

Elm is a statically typed functional language designed for front-end development. Its type system, exhaustive pattern matching, and explicit modeling of application state can make many common mistakes detectable during compilation.

For example, consider an application that represents a user account with several possible states:

  • Loading
  • Loaded successfully
  • Failed to load

A loosely modeled application might represent these states using several independent variables. That allows contradictory combinations: a request can be marked as both successful and failed, or the application can attempt to display data that does not exist.

Elm encourages developers to represent these alternatives explicitly using custom types and to handle them through pattern matching.

The compiler can then identify missing cases when the model changes.

Elm’s compiler also reports errors when code no longer conforms to the declared types. Its compiler is famously helpful when refactoring because changes to data structures and function signatures reveal many affected parts of the application.

This does not eliminate logical errors, incorrect business rules, or every runtime failure. It does make certain classes of mistakes harder to introduce unnoticed.

Haskell: stronger abstractions for expressing invariants

Haskell takes a similar philosophy further, offering algebraic data types, pattern matching, type classes, higher-order functions, and advanced type-system features.

These capabilities allow developers to encode more of a system’s intended behavior in its types.

For example, instead of representing an operation as a function that accepts arbitrary input and may fail in an unspecified way, a developer can model its valid inputs and possible outcomes explicitly.

Depending on the problem, types can express constraints around parsing, validation, state transitions, and error handling. The compiler can then enforce aspects of those constraints throughout the program.

The type system does not prove every business requirement correct by default. Nevertheless, a well-designed type model can prevent entire categories of invalid states and make assumptions visible in the code.

Why this matters for small teams

Imagine a project maintained by one developer or a team of two or three people, without dedicated QA engineers.

Every change carries a verification cost. Tests must be written, executed, interpreted, and maintained. Manual testing consumes time. Regression bugs can go unnoticed because there is nobody else routinely reviewing the system.

A language that shifts some of this verification into compilation can offer a structural advantage.

The benefit is not that testing becomes unnecessary. The benefit is that the compiler becomes another continuously available line of defense.

A useful way to think about it is:

The more correctness properties the language can enforce automatically, the less the team must rely exclusively on human discipline and runtime testing to preserve them.

For a small team, this can be a more valuable advantage than a modest improvement in execution speed.

3. Deployment: can the program run where it needs to run?

A language can be technically excellent and still be the wrong choice if deploying its applications is difficult or incompatible with the target environment.

Deployment constraints are concrete and often decisive.

For a browser application, relevant questions include:

  • Can the application run in the required browsers?
  • What JavaScript output, compatibility, or runtime support is required?
  • How large is the delivered application?
  • Are there restrictions on browser APIs or the execution environment?

For a backend service:

  • Is the language supported by the target hosting platform?
  • Does it require a particular application server or runtime?
  • How easy is it to build, package, deploy, and roll back?
  • Can the application run in the available containers or serverless environment?
  • What operating-system and architecture dependencies exist?

For mobile and embedded applications, hardware support, platform APIs, binary size, memory usage, and toolchain availability can be even more important.

These constraints should be evaluated before selecting a language, not after the application has been built.

A deployment model that works perfectly in a developer’s environment but requires substantial additional infrastructure in production introduces cost and operational risk.

4. Ecosystem maturity: how much infrastructure must you build yourself?

A language’s ecosystem determines how much existing engineering work you can reuse.

Consider the availability and quality of:

  • Libraries for required integrations and protocols.
  • Frameworks and established architectural patterns.
  • Database drivers and migration tools.
  • Testing, debugging, profiling, and observability tools.
  • Security updates and maintained dependencies.
  • Documentation and practical examples.

A mature ecosystem can significantly reduce development time and the amount of custom code that must be maintained.

However, the total number of packages is not a useful metric on its own. A smaller ecosystem may be entirely adequate for a specialized application, while a large ecosystem may still lack a critical integration.

The objective question is whether the language and its ecosystem provide the capabilities the project actually needs, at an acceptable maintenance and security cost.

5. Developer availability: how expensive is it to build and maintain the team?

Hiring and knowledge transfer are real engineering costs.

A language with a large pool of experienced developers is usually easier to staff. A niche language may require more recruitment effort, longer onboarding, or reliance on a small number of specialists.

The implications extend beyond hiring. Consider what happens if the original developer leaves, the project needs urgent support, or a new team must take ownership.

But popularity should not automatically override every other criterion.

For a small, stable application maintained by one experienced developer, a language that substantially improves correctness or productivity may be worth its smaller talent pool.

For a large organization with frequent team changes, broad hiring availability may carry much more weight.

The correct decision depends on the project’s expected lifetime, staffing model, and operational risk.

6. Maintainability: optimize for the lifetime of the system

Writing code is only one part of software development. Reading, modifying, debugging, and extending existing code can consume far more effort over the lifetime of a system.

Maintainability depends on several factors:

  • How clearly the language expresses the domain model.
  • Whether the type system makes invalid states difficult to represent.
  • How safely the code can be refactored.
  • Whether the compiler identifies the consequences of changing interfaces.
  • How easily developers can understand data flow and error handling.
  • Whether the ecosystem provides stable, well-supported abstractions.

A language that allows a developer to write an initial implementation quickly may not minimize the cost of maintaining it for ten years.

Conversely, a language with a steeper learning curve may pay for itself if it consistently reduces debugging and refactoring effort.

This is one reason functional programming and strong static typing deserve consideration even when they are not the dominant choice in a particular market.

The relevant metric is not how quickly someone can write the first version. It is how much effort is required to keep the software correct as requirements evolve.

7. Productivity: measure the whole development process

Productivity is frequently used to justify subjective language preferences. One developer says a language is more productive because its syntax is concise. Another prefers a language because its abstractions feel natural.

Neither statement is sufficient on its own.

A more useful assessment considers the complete development cycle:

  1. How long does it take to implement a feature?
  2. How much code must be written and maintained?
  3. How many defects are introduced?
  4. How difficult are debugging and refactoring?
  5. How much testing and manual verification are necessary?
  6. How expensive is deployment and ongoing operation?

Concise syntax can help, but it does not automatically translate into fewer defects or lower maintenance costs.

Similarly, a strong type system can impose some upfront modeling effort while reducing the cost of subsequent changes.

For a one-person project, these trade-offs can be particularly significant. The developer has limited time and cannot compensate indefinitely for weak tooling or a language model that makes mistakes difficult to detect.

8. A practical framework for choosing a language

Instead of asking which language is best, start by identifying the project’s constraints and assigning priorities to the relevant criteria.

CriterionObjective question
PerformanceDoes it meet measured latency, throughput, and resource requirements?
CorrectnessWhich classes of errors can be detected before runtime?
DeploymentDoes it run reliably in the required target environment?
EcosystemAre the necessary integrations and tools available and maintained?
Team availabilityCan the system be staffed and supported over its lifetime?
MaintainabilityHow expensive are debugging, refactoring, and future changes?
ProductivityWhat is the total cost of implementing and operating the system?
Operational riskCan the team diagnose, deploy, recover, and support the application reliably?

Not every criterion has equal importance for every project.

For a small embedded controller, memory usage and timing guarantees may dominate the decision.

For a browser application maintained by one developer, deployment compatibility and compile-time correctness may be more important than raw execution speed.

For a high-throughput numerical workload, benchmark results and hardware utilization may be decisive.

For an enterprise system expected to survive multiple generations of developers, ecosystem maturity, hiring, and maintainability may outweigh nearly everything else.

The goal is not to assign every language a universal score. It is to identify the constraints that matter, eliminate unsuitable options, and compare the remaining candidates against the same requirements.

9. Where personal preference fits

Personal preference is not entirely irrelevant. Familiarity with a language can reduce onboarding costs, and developers who understand a language deeply are often more productive with it.

But familiarity should be treated as an economic consideration, not as proof that the language is technically superior.

A preference becomes an engineering argument when it can be connected to a measurable outcome: fewer defects, shorter delivery times, easier hiring, simpler deployment, or lower maintenance costs.

Statements such as “I prefer the syntax,” “this language feels cleaner,” or “everyone uses it” may be useful starting points for discussion. They are not sufficient grounds for a major architectural decision.

The same applies to fashionable claims about performance. A benchmark showing that one language wins a particular microbenchmark does not establish that it is the right choice for an application with different workloads and constraints.

Engineering decisions require evidence relevant to the system being built.

Conclusion: choose the tool that minimizes total engineering cost

Programming languages are not interchangeable in every practical situation. Their runtimes, type systems, deployment models, ecosystems, and hardware constraints create meaningful differences.

But those differences should be evaluated in terms of the outcomes they produce.

A language that generates exceptionally fast code may be unsuitable for a memory-constrained embedded device. A language with a smaller ecosystem may be ideal for a specialized application. A strongly typed functional language may be particularly valuable for a small team that needs to minimize the risk of regressions without dedicated QA resources.

There is no universally best programming language. There are languages that are better or worse suited to a specific set of requirements.

The most useful question is therefore not:

Which programming language do I like best?

It is:

Which language gives this team the best combination of performance, correctness, deployment simplicity, and long-term maintainability under the constraints of this project?

That is an engineering decision. And like any good engineering decision, it should be justified by evidence rather than preference.

When Software Turns Into a Swamp: The Natural Evolution of Complex Systems

Why software systems become harder to change over time — and why that doesn’t necessarily mean they were badly built.

A software system can work perfectly well for years and still become increasingly difficult to maintain, extend, and understand.

Adding a seemingly simple feature starts requiring changes across multiple modules. Developers hesitate to touch certain parts of the codebase because the consequences are difficult to predict. A change that once took a day now takes a week, and every release seems to carry a little more risk than the previous one.

Eventually, someone looks at the system and asks the obvious question:

How did this become such a mess? Was it badly designed from the beginning?

Sometimes, the answer is yes. But often, the reality is more interesting — and more difficult to address.

Software doesn’t evolve in a vacuum

When a system is first built, its scope is relatively well understood. The business has a set of requirements, the team designs an architecture around them, and the resulting software reflects a particular understanding of how the business operates.

But businesses don’t stand still.

Users change their expectations. New revenue streams emerge. Regulations introduce new constraints. Integrations with external systems become necessary. Priorities shift, deadlines tighten, and features that once seemed unimportant suddenly become critical.

The software must adapt.

Meanwhile, the original architecture was designed around yesterday’s understanding of the problem. New requirements don’t always fit naturally into existing abstractions, so developers introduce exceptions, add new dependencies, or extend components beyond their original responsibilities.

Individually, these decisions can be entirely reasonable. The alternative might be delaying an important feature, breaking existing behavior, or spending months redesigning a system that the business needs today.

The difficulty is that every decision changes the environment in which future decisions will be made.

Software architecture is not a blueprint that can simply be followed forever. It is a set of assumptions about a business that keeps changing.

The cost of many reasonable decisions

There is another factor: software is rarely built and maintained by one person, following one consistent vision, for its entire lifetime.

Developers join and leave. Teams reorganize. Different people make different trade-offs, often with limited knowledge of the decisions made before them. Documentation becomes outdated, and some of the reasoning behind the original architecture disappears.

Even when everyone involved is competent, maintaining architectural consistency becomes increasingly difficult.

Consider a component originally designed to handle one specific responsibility. Over several years, it acquires additional responsibilities because it is already the most convenient place to implement related functionality.

Other components begin depending on it. Eventually, changing it requires understanding interactions that were never part of its original design.

Nobody necessarily made one catastrophic mistake. Instead, a series of locally sensible decisions gradually produced a globally complicated system.

This is one of the most misleading aspects of software maintenance: a system can become difficult to work with without having been obviously badly engineered at any particular point in its history.

Of course, poor architectural decisions, insufficient testing, excessive coupling, and inadequate technical discipline can accelerate this process. Complexity is not an excuse for bad engineering. But neither is complexity, by itself, proof of it.

Enter the swamp

Imagine walking through a field.

At first, the ground is firm. You know where you’re going, and every step takes you forward. You can change direction easily because the terrain is predictable.

Now imagine that the field gradually becomes a swamp.

The destination hasn’t changed, but moving toward it becomes harder. Each step requires more effort. You need to understand the ground before putting your weight on it. A route that worked yesterday may no longer be the safest one today.

Mature software systems can develop the same characteristics.

The code still runs. The business still depends on it. New features are still being delivered. But the effort required to make each change increases because the relationships between components have become harder to understand and predict.

The problem is no longer just the complexity of individual modules. It is the complexity of their interactions.

A small change can affect an unexpected part of the system. A component cannot easily be replaced because too many other components depend on its behavior. A seemingly isolated improvement requires coordinating changes across multiple teams or services.

The system becomes a swamp: progress is still possible, but increasingly expensive.

And the larger the system becomes, the more costly it is to ignore the problem.

Why adding more architecture doesn’t always help

When a system becomes difficult to maintain, the instinctive response is often to introduce a new architectural pattern, split components into services, or begin planning a complete rewrite.

Sometimes these are the right decisions. Sometimes they make the situation worse.

Microservices, for example, can help isolate responsibilities and allow teams to evolve components independently. But splitting a system into more services also introduces communication overhead, distributed failure modes, deployment complexity, and additional operational work.

If several services are tightly coupled, change together, and exist mainly to call one another, distributing them across a network may simply make the original complexity more expensive.

Conversely, a monolithic application is not inherently a problem. A well-structured monolith can be easier to understand, test, deploy, and modify than a collection of poorly separated services.

The goal is not to adopt a particular architecture. It is to find an architecture that matches the system’s current responsibilities, constraints, and business needs.

That match can change over time.

Realigning the architecture

If a system naturally accumulates complexity as the business evolves, the answer is not to expect its original architecture to remain appropriate forever.

The answer is to periodically realign the architecture with the system as it actually exists.

This doesn’t necessarily mean rebuilding everything. Often, targeted interventions can produce significant improvements.

1. Isolate the parts that are difficult to change.

Some components become deeply embedded in the business and expensive to modify safely. Instead of repeatedly spreading their complexity throughout the codebase, we can isolate them behind a well-defined interface or communication layer.

The rest of the system interacts with that interface rather than depending directly on the component’s internal implementation.

This creates room to improve, replace, or eventually retire the component without requiring the entire system to change with it.

The component may remain complex internally. The important difference is that its complexity becomes contained rather than contagious.

2. Reconsider service boundaries.

A system that has grown into dozens of microservices may no longer benefit from having so many independently deployed units.

If several services have strongly overlapping responsibilities, change together frequently, or require constant coordination, consolidating them into a larger, more cohesive service can reduce network communication, operational overhead, and the cognitive burden of making changes.

This is not a step backward. It is a correction to an architecture that may no longer reflect the system’s actual needs.

3. Restore clear boundaries and responsibilities.

Over time, components often accumulate responsibilities that do not naturally belong together. Carefully separating these responsibilities, reducing unnecessary dependencies, and establishing explicit contracts can make the system easier to reason about.

The objective is not architectural purity. It is to make future changes more predictable and less expensive.

4. Treat architectural improvement as ongoing work.

Architectural realignment is most effective when it is not postponed until the system becomes almost impossible to change.

Regularly identifying the parts of the system where complexity is growing fastest allows teams to make focused improvements before the cost of intervention becomes prohibitive.

This requires engineering judgment, an understanding of business priorities, and the willingness to invest in changes that may not deliver an immediate user-facing feature.

It also requires recognizing that keeping software operational and keeping it easy to evolve are two different achievements.

The goal is not to eliminate complexity

A mature business is inherently more complex than it was when it started. Its software will often reflect that reality.

Trying to force a growing business into an architecture designed for its earliest stage can be just as damaging as allowing the system to evolve without any architectural direction.

Some complexity is essential. Some is the accumulated cost of past decisions. Some comes from requirements that no longer exist but whose implementation remains. And some comes from architectural boundaries that no longer make sense.

The challenge is distinguishing between them.

There is no universal architecture that remains optimal for every system throughout its lifetime. There is no single refactoring technique that can solve every maintenance problem, and no rewrite that guarantees a better outcome.

What matters is understanding where the system is becoming difficult to change, why that difficulty exists, and which architectural adjustments will provide the greatest benefit.

Keep the system moving

Software becoming more complex over time is not, in itself, a failure. It is a natural consequence of software serving a business that continues to evolve.

But complexity left unattended accumulates interest.

Eventually, the system becomes a swamp in which every step requires more effort, more caution, and more resources than before.

The answer is not necessarily to drain the entire swamp and start over. It may be enough to build a few solid paths, isolate the unstable ground, and redirect traffic around the areas that have become too difficult to navigate.

In software terms, that means making deliberate architectural adjustments: introducing boundaries where they are needed, removing them where they no longer help, and reshaping the system to reflect its current reality.

Good software engineering is not about building a system that never becomes complex. It is about preventing that complexity from making the system unnecessarily difficult to change.

At bitGloss, we work with software systems that have evolved over years of real business use. Our focus is not on replacing everything simply because it has become complicated, but on identifying the sources of friction and finding practical ways to make the system easier to maintain, faster to evolve, and better aligned with the business it serves.

Beware of the Title Chasers: How to Spot Power-Seekers in Tech Teams

Every engineering organization has ambitious people, and ambition is healthy. Wanting to grow, lead, or take on more responsibility is a natural part of a career. But there is a different type of person that is worth recognizing early: the one who is not chasing growth, but chasing position.

What the pattern looks like

The title chaser is rarely driven by the work itself. Their energy goes into climbing, not building. Common traits include:

  • Weak craft, strong ambition. Their technical or professional track record is often thin. Instead of deepening their skills, they look for a role where skill matters less than control.
  • Problems are currency. Rather than solving issues, they tend to generate, amplify, or prolong them, because a team in crisis needs someone to “take charge.” A calm, well-functioning project gives them nothing to rescue.
  • Psychological levers instead of arguments. When they cannot win on technical merit, they rely on guilt-tripping, veiled threats, emotional pressure, or making others feel responsible for failures they did not cause.
  • Hierarchy as identity. They constantly signal rank, often through language. Phrases like “back when I was just a lowly frontend developer” or “my devs…” are quiet markers: the first positions the speaker as someone who has risen above the craft, the second frames people as possessions rather than colleagues.
  • Credit upward, blame downward. Successes become personal achievements; failures belong to “the team.”

Why they are dangerous

The damage rarely comes from a single incident. It accumulates.

Toxicity spreads. Teams adopt the behaviors that get rewarded. When manipulation and posturing are tolerated or promoted, others learn that they work.

Similar people gather. Power-seekers tend to surround themselves with people who share their approach, or who are easy to control. Over time, a small circle forms where loyalty matters more than competence.

Good people leave. Skilled, independent professionals are the first to notice the dynamic and the first to walk away, because they can. What remains is a team that is easier to manage but weaker to deliver.

Projects suffer. Decisions are made to protect status rather than to serve the product. Risks get hidden, honest feedback disappears, and technical debt grows because raising problems becomes personally dangerous.

Warning signs to watch for

  1. They are more interested in who owns a decision than in whether it is right.
  2. Conflicts seem to follow them from team to team.
  3. They rely on private conversations, shifting narratives, and selective information.
  4. Feedback is taken as an attack, and critics are quietly sidelined.
  5. Their own technical contributions are hard to point to, yet their influence keeps growing.
  6. They get uncomfortable when others become competent or visible.

No single sign is conclusive. A pattern across several, sustained over time, is.

What to do about it

For individual contributors

  • Document important decisions and agreements in writing. Clarity is hard to manipulate.
  • Do not accept guilt or blame that is not yours. Ask for specifics: what happened, what evidence, what outcome is expected?
  • Build relationships across teams so that you are never dependent on one person’s opinion of you.
  • Know when to leave. No project is worth your professional health.

For managers and leaders

  • Evaluate people on outcomes and on how those outcomes were reached. A “high performer” who leaves a trail of burnt-out colleagues is not a high performer.
  • Pay attention to attrition. Repeated departures from one person’s team are data.
  • Promote based on demonstrated skill and collaboration, not on confidence and self-promotion.
  • Create safe channels where concerns can be raised without retaliation.

For organizations

  • Make technical career paths as rewarding as management paths, so that leadership is a choice for those who want to serve a team, not an escape for those who cannot grow technically.
  • Treat psychological safety as a delivery factor, not a soft perk.

A necessary caution

Not every manager is a power-seeker, and not every difficult colleague is toxic. Leaders who came up through the ranks and mention their past do not automatically fit this pattern. The difference lies in intent and behavior: a healthy leader uses their experience to help others grow, while a title chaser uses it to elevate themselves. Judge by actions, consistently observed, rather than by single phrases or isolated bad days.

Final thought

Great teams are built on trust, competence, and honest communication. Those who chase titles for the sake of power erode all three. Recognizing the pattern early, and refusing to reward it, protects both the people and the products they build.

When the Technology Doesn’t Fit: Rescuing a Platform Buried Under Workarounds

A story from the field, with names changed to protect the innocent.

A few years ago, we were brought in to help a client whose platform had a reliability problem. Almost every week, the site went down, and the cause was usually the same: the database buckled under load. Every new feature took longer to ship than the last, incidents were hard to diagnose, and each new batch of users made things worse.

The root cause wasn’t a weak team or a bad product idea. It was a technology choice made years earlier, and everything that had been built around it ever since.

The setup

The platform had been built from scratch by an independent consultant, whom we’ll call Mark X. He built it on XSpaces, an open-source component framework built on JSF and publicly available. What made the choice unusual is that Mark had written XSpaces himself. Nobody knew the technology better, and for a while the platform did what the client needed.

But a technology can be well crafted and still be the wrong one for the job. This platform needed to grow, and its foundation wasn’t built for that.

What we found

  • The stack was heavy to write, read and maintain. Even simple changes meant navigating layers of component abstractions and configuration, which made the code slow to understand for anyone who hadn’t lived in it for years.
  • The architecture didn’t scale with users. JSF’s server-side component model keeps a lot of state per session, and the way the application interacted with the database put growing pressure on it as concurrency increased.
  • Workarounds were piled on workarounds. Whenever the platform hit the limits of the framework, the answer was a patch. Over the years these patches became load-bearing: some compensated for the framework’s shortcomings, others for the side effects of earlier patches, and some of them added to the very database load that was causing the outages.

The result was a system that fell over almost weekly, where changing one thing risked breaking three others, and where every incident was a firefight rather than a fixable problem.

The human side

This wasn’t purely a technical story. XSpaces was Mark’s own creation, and the platform was built on it, so he was understandably protective. Suggestions to simplify or move away from parts of the stack were often met with resistance, because when the technology is your own work, every critique of it feels personal.

We tried to approach it with empathy. The choices had made sense at the time, and he had kept the platform running for years. But the risk to the client was real: a critical system whose inner workings, and all its accumulated workarounds, lived mostly in one person’s head.

After a few years, Mark moved on, and that’s when the real work began.

Taking over

A handover like this is never clean. Our first priority wasn’t to rewrite anything, but to stop the bleeding and understand what we had.

  1. Reduce the outages. We started with the database: finding the heaviest queries and access patterns, fixing the worst offenders, and adding monitoring so we could see what the platform was really doing under load.
  2. Map and document. We traced the critical flows end to end, separated essential workarounds from historical accidents, and wrote down what had only existed as tribal knowledge.
  3. Add safety nets. We put safeguards in place around the most critical flows, so we could change things without fear.

The phase-out strategy

A full rewrite was tempting, and it would have been the wrong call. The business depended on the platform, and big-bang rewrites have a long record of running late and over budget.

Instead, we designed a gradual phase-out of the JSF-based stack, in the spirit of the strangler fig pattern:

  • New functionality was built on a simpler, more mainstream architecture from day one, so the legacy part stopped growing.
  • Existing modules were migrated one at a time, ordered by business value and pain rather than technical elegance.
  • Workarounds whose reason for existing had disappeared were removed instead of ported over.
  • The overall architecture was simplified, with fewer layers, clearer boundaries and less hidden state, and a much lighter footprint on the database.

Throughout, the platform stayed live and users kept working.

The results

Over time, the platform went from fragile to dependable:

  • Incidents went from almost every week to once every few months.
  • It can handle far more concurrent users, with much more headroom than before.
  • Maintenance is predictable, and new developers get productive much faster on a simpler codebase built on mainstream technology.
  • The client no longer depends on any single person to keep the system running.

What we took away from it

Choose technology for where the product is going, not just where it is today. A stack that works for hundreds of users can quietly become the bottleneck at thousands.

Be wary of niche technology, even when it’s open source. Publishing a framework doesn’t create a community around it. If the only real expert is the person who wrote it, the bus factor is still one.

Recurring incidents are a signal, not a fact of life. If the platform goes down every week, the fix is rarely another patch. It’s usually the architecture.

Workarounds are debt with interest. Each one is cheap to add and expensive to live with, and eventually someone pays the bill.

Migrate incrementally. Evolution beats revolution when the system has to keep running.

Treat the people with respect. Legacy code is never just code. Someone made those choices under real constraints, and the best rescues honor that while still moving forward.

At bitGloss, this is the kind of work we enjoy: stepping into a complicated situation, understanding it before judging it, and leaving the client with a platform that’s simpler, faster and easier to own. If your team is dealing with a system that has become harder to change than to live with, we’d be glad to talk.

Cum a apărut worklog.ro: un tool intern care a devenit produs

La bitGloss nu ne-am propus niciodată să construim un produs doar ca să avem un produs.

worklog.ro a apărut dintr-o nevoie foarte practică: să ne organizăm mai bine munca, să urmărim timpul investit în proiecte și să putem genera rapoarte fără să ne adaptăm permanent după limitările altor aplicații.

Și, ca multe produse bune, a început ca un tool intern.

Problema pe care încercam să o rezolvăm

În teorie, problema era deja rezolvată.

Existau și există multe produse mature pentru time tracking și project management. Am folosit și noi astfel de soluții. JIRA era prezent în ecosistem, iar o perioadă am folosit și YouTrack instalat pe infrastructura proprie.

Dar apăreau constant mici probleme:

  • unele fluxuri de lucru nu se potriveau modului nostru de lucru;
  • raportarea nu era suficient de flexibilă pentru ce aveam nevoie la acel moment;
  • modelul de cost nu era întotdeauna convenabil;
  • trebuia să combinăm mai multe unelte pentru ceva ce, în esență, voiam să fie un proces unitar.

Nimic dramatic.

Doar suficient de multă fricțiune încât să ne întrebăm: dacă tot adaptăm procesele după tool, de ce să nu construim exact ce ne trebuie?

Așa a apărut prima versiune worklog.

Prima versiune: strict pentru noi

Inițial nu exista intenția să devină produs.

Aveam nevoie de ceva simplu:

  • logare de ore;
  • organizare pe proiecte;
  • generare de rapoarte;
  • administrarea accesului pentru echipă.

Frontend-ul inițial a fost construit în ClojureScript.

A funcționat bine și ne-a permis să iterăm rapid. Dar pe măsură ce aplicația a crescut și utilizarea a devenit zilnică, am început să observăm un lucru important: frontend-ul era întreținut de o singură persoană.

În acel context, viteza de dezvoltare conta, dar controlul calității conta și mai mult.

Așa am decis să migrăm frontend-ul către Elm.

Motivul nu a fost „tehnologie nouă” sau dorința de rescriere. Motivul a fost unul foarte pragmatic: Elm mută foarte multe categorii de erori în etapa de compilare. În practică, asta înseamnă mai puține bug-uri care ajung în producție și mai puțin efort de validare manuală.

Pentru o aplicație întreținută eficient de o echipă mică, asta s-a dovedit o alegere foarte bună.

Câțiva ani de utilizare reală

Nu există test mai bun pentru un produs decât să depinzi de el.

Ani la rând am folosit worklog exclusiv intern.

Fiecare neajuns ieșea imediat la suprafață.

Dacă un raport dura prea mult să fie generat, îl simțeam.
Dacă lipsea o funcționalitate, apărea rapid în backlog.
Dacă ceva era greoi, nimeni nu evita problema.

În perioada aceea am trecut prin mai multe iterații și am rafinat produsul continuu.

Momentul în care am renunțat la Excel

Până în 2021, facturarea noastră se făcea încă prin Excel.

Funcționa.

Dar doar în sensul că orice proces manual „funcționează” până când devine suficient de enervant.

Generarea facturilor era repetitivă, predispusă la greșeli și consuma timp inutil.

În 2021 am generat prima factură direct din worklog.

A fost unul dintre momentele în care am realizat că produsul începe să rezolve mai mult decât problema inițială de time tracking.

Pur și simplu era mai simplu.

Legislația a schimbat direcția produsului

Între timp, cadrul fiscal din România s-a schimbat.

A apărut necesitatea integrării cu SPV și e-Factura.

În loc să tratăm asta ca pe încă un tool separat, am ales să integrăm fluxul direct în platformă.

Astfel, worklog a început să lege natural:

ore lucrate → raportare → facturare → transmitere.

Când ne-am dat seama că nu suntem singurii cu problema asta

După ani de utilizare internă, a devenit clar că modelul nostru nu era deloc unic.

Existau mulți oameni și multe firme care funcționau similar:

  • freelanceri;
  • PFA-uri;
  • SRL-uri mici;
  • agenții;
  • echipe care facturează la oră;
  • firme care lucrează atât cu clienți români, cât și internaționali.

Toți aveau nevoie de aceleași lucruri:

  • time tracking;
  • rapoarte;
  • facturare;
  • integrare cu cerințele fiscale;
  • administrare fără complexitatea unei platforme enterprise.

Atunci am decis să deschidem worklog.ro și către exterior.

Nu ca idee de startup.

Ci ca produs validat deja prin ani de utilizare internă.

Funcționalități care au apărut din utilizare reală

Pe parcurs am adăugat funcționalități care au rezolvat probleme concrete:

Integrare GitHub
Posibilitatea de a loga ore pornind de la commit-uri extrase direct din GitHub.

Integrare JIRA
Logarea timpului pornind de la tichetele existente.

Arhitectură multi-tenant
Fiecare cont are:

  • un administrator;
  • unul sau mai mulți utilizatori;
  • proiecte separate.

Administratorul este și el utilizator — poate loga ore ca oricine altcineva — dar are responsabilități suplimentare:

  • generare facturi;
  • management proiecte;
  • management utilizatori;
  • asocieri între utilizatori și proiecte;
  • controlul accesului în diferitele module.

Modelul a fost construit după cum funcționează firme reale, nu după cum arată un demo.

De ce publicăm povestea asta

Există multe produse care promit multe.

Noi preferăm altă abordare.

worklog.ro nu a fost construit pentru a intra pe o piață.

A fost construit pentru că aveam nevoie de el.

Faptul că astăzi este disponibil și pentru alții este doar continuarea firească a unei aplicații pe care am folosit-o ani întregi înainte să o prezentăm public.

Is Your Tech Stack Appropriate or Are You Just Trying to Be Fancy?

When a software project starts struggling, the first instinct is often to blame the code.

But in many cases, the real problem sits one level higher: the architecture and technology choices themselves.

We’ve seen systems built around microservices long before they had enough scale to justify them. We’ve seen multiple frontend frameworks living in the same application. We’ve seen message queues, event buses, service meshes, and orchestration layers solving problems that didn’t actually exist.

The result is predictable: development slows down, debugging becomes harder, onboarding takes longer, and every new feature costs more than it should.

The challenge is that it’s difficult to evaluate an architecture from diagrams and presentations alone.

We Don’t Start With Opinions

Before we assess your technology stack, we need to understand it.

That’s why we don’t begin with a slide deck full of recommendations. We start by working inside your system.

We fix bugs. We implement features. We deal with the same constraints your team deals with every day.

This hands-on involvement gives us a realistic understanding of how your application behaves in production, how your development process works, and where complexity is helping versus hurting.

Only after we’ve worked within your codebase do we form conclusions.

The Questions We Try to Answer

Is your Spring microservices architecture genuinely supporting your scale and organizational needs?

Or would a simpler modular monolith allow your team to move faster?

Are those message queues providing real business value?

Or are they introducing operational complexity that could be replaced by straightforward API communication?

Do you actually need multiple frontend frameworks?

Or are they creating fragmentation, duplicated knowledge, and inconsistent user experiences?

Most importantly:

What complexity is essential, and what complexity is self-inflicted?

An Honest Technical Assessment

After gaining real experience with your system, we’ll give you a straightforward assessment.

If the current architecture makes sense, we’ll tell you.

If parts of it are over-engineered, we’ll explain exactly why.

We’ll identify components that can be consolidated, technologies that can be removed, and areas where complexity can be reduced without sacrificing business capabilities.

Our recommendations are practical and grounded in experience with your codebase—not architectural fashion trends.

Simplicity Is Often a Competitive Advantage

Technology teams rarely suffer because they have too little complexity.

More often, they suffer because complexity accumulates faster than business value.

Every service, framework, integration point, and infrastructure component carries a maintenance cost. Over time, those costs compound.

The best architecture is rarely the most sophisticated one.

It’s the one that solves today’s problems while remaining easy to understand, maintain, and evolve tomorrow.

That’s what we help our clients achieve.

Not more technology.

The right amount of technology.


Every architecture decision has a cost.

If you’d like an honest assessment based on real hands-on work – not diagrams – we’d love to hear about your project.

bitgloss.ro

Making Java code more readable with Type Abstractions

Let’s consider a simple example project, appointments, where each appointment has a date, doctor, patient, and comments. These records are read from input, displayed in list or tabular form and saved. The code involves multiple layers — reading and writing I/O, formatting data, and writing formatted views.

Here’s the actual Java representation of an appointment:

public record Appointment(LocalDate date, String doctor, String patient, String comments) {}

Early in the design, many of the functions that manipulate these appointments had verbose, deeply nested type signatures like BiFunction<List<String>, Supplier<Stream<List<String>>>, String>. While correct, such signatures obscure intent. That’s where type abstractions come in — small, domain-specific type aliases that make the structure of the code clearer and more readable.

The Problem: Overly Complex Function Signatures

Consider this example, which takes a list of headers and a content supplier, producing a string representation of a list view (the table view is omitted for brevity) :

public static BiFunction<List<String>, Supplier<Stream<List<String>>>, String>
listFormat =
    (headers, content) ->
        header(headers).append(data(content)).toString();

This works, but the type signature is long and opaque. It doesn’t immediately tell you what the function is about — only that it’s a BiFunction of lists and streams that returns a string.

The Solution: Creating Semantic Type Aliases

Java doesn’t have native type aliases, but we can emulate them through interfaces that extend existing types. For instance, we can define a View type that captures the concept of a function that takes headers and content, and returns a formatted string:

public interface Types {
    interface View extends BiFunction<
            Collection<String>,
            Supplier<Stream<Collection<String>>>, String> {}
}

With this, our previous function becomes much cleaner:

public static View listFormat = (headers, content) ->
  header(headers).append(data(content)).toString();

The functionality hasn’t changed — but the intent is now explicit. We’ve moved from a generic, mechanical type to a domain-level concept: a View.

Extending the Abstractions

Next, the display function — responsible for rendering appointments — takes a View (the formatter) and returns a ViewWriter<IO> (the executor that writes the formatted output to an IO stream). Originally, its signature was difficult to read:

public static Function<
    BiFunction<List<String>, Supplier<Stream<List<String>>>, String>,
    BiFunction<List<String>, Supplier<Stream<List<String>>>, Consumer<IO>>>

display = ...

Using type abstractions, this becomes far more expressive:

public static Function<View, ViewWriter<IO>> display =
view ->
  (headers, content) -> io ->
    io.print(content.get().count() == 0 ? "No appointments found\n"
      : view.apply(headers, content));

Here’s how the types break down:

  • View — the formatter: takes headers and content, produces a string representation of the table.
  • ViewWriter<W> — the executor: takes headers and content, and produces a side effect by writing the formatted string to a consumer of type W (like IO).
interface ViewWriter<W> extends BiFunction<
        Collection<String>,
        Supplier<Stream<Collection<String>>>,
        Consumer<W>> {}

This separation keeps formatting logic distinct from side-effect logic: View handles what the output looks like, and ViewWriter handles where it goes.

Abstracting Read and Write Operations

Finally, the function responsible for reading a new appointment from input and writing it is defined as:

public static ReadWriter<Appointment, TypedIO> addNew =

    writer ->
      reader ->
        writer.accept(new Appointment(
            reader.readDate("Enter date: ", "invalid date"),
            reader.readString("Enter doctor: ", "").orElse(""),
            reader.readString("Enter patient: ", "").orElse(""),
            reader.readString("Enter comments (if any): ", "").orElse("")));

Its type alias is equally simple and expressive:

interface ReadWriter<R, W> extends Function<Consumer<R>, Consumer<W>> {}

Rather than dealing with nested Function<Consumer<X>, Consumer<Y>> constructs, we now have a ReadWriter — a function that connects two side effects: reading and writing.

The Result

These abstractions don’t change the runtime behavior of the code. They change its shape.
Now, a developer reading Function<View, ViewWriter<IO>> can immediately tell that the function takes a view and returns a view writer — no decoding of generic types required.

The result is clearer, safer, and more expressive code. Type abstractions let us represent not just data, but also intent, in the type system. They make it easier to reason about functions, compose behaviours, and test side effects in isolation — all without adding boilerplate or runtime overhead.

(Adapted from Chapter 3, “Type Abstractions,” in Dr. Software, available at bitgloss.ro/dr-software.pdf

Full example code found here)

Artificial Intelligence: A misleading name for powerful tools

This article is a follow-up of my 2019 article on the same topic, but updated in the light of today’s AI hype.

Like I mentioned then, when I first experimented with neural networks years ago, I was struck by how little “intelligence” there actually was in them. They did exactly what I coded them to do—no more, no less. Today’s systems may look more impressive, but the principle hasn’t changed: these are tools, not minds.

The Problem With the Word “Intelligence”

Humans don’t even have a precise definition of their own intelligence or consciousness. If we can’t define those terms for ourselves, it makes no sense to claim that we’ve created them in machines.

What we can say is that current AI systems don’t exhibit the qualities most people intuitively link with intelligence: self-awareness, understanding, intent, or meaning. They generate outputs by compressing patterns in data, not by reasoning or knowing.

To call that “intelligence” is to confuse statistical mimicry with cognition.

What AI Actually Does

Modern AI systems, especially large language models and generative tools, are best understood as:

• Pattern machines. They excel at finding correlations in vast amounts of data.

• Automation engines. They can handle repetitive, data-heavy tasks quickly and consistently.

• Amplifiers. They extend human capability, but only within boundaries set by training data and design.

This is powerful, but it is not thought.

The 2025 Reality Check

• Scale isn’t sentience. More data and bigger models don’t bring us closer to human-like understanding.

• Usefulness ≠ understanding. A tool can be highly practical without being intelligent.

• The real risks are human. Bias, misuse, privacy abuse—these are problems in how people deploy the systems, not evidence of AI “deciding” anything.

Why the Distinction Matters

If we keep pretending AI is a kind of mind, we risk treating its outputs as if they were grounded in meaning or truth. They aren’t. They’re grounded in probabilities.

AI is not intelligent because we don’t even know what that word would mean in this context. What we do know is that these systems are fundamentally different from human thought: they calculate, predict, and generate—but they do not understand.

Conclusion

The danger isn’t that AI will “wake up.” The danger is that humans will forget what it actually is: computation dressed up in human-like outputs. Powerful, yes. Useful, yes. But never a mind.

Automation

A term thrown randomly in lots of software development shops, to describe different things. Why? Automation is a simple term, that used to mean a process that can run without (or with minimal) human intervention. Now it means lots of things and, in most cases, it’s a lie. Let me explain what I mean, through a software development skit:

Alice: Bob, where do we stand with the QA automation for this release?

Bob: We’ll probably have to modify 40% of the regression suite, ’cause lots of tests are failing.

Alice: Do you think we’ll make it in time?

Bob: We could bring in 5 guys from John’s team to help.

Alice: Sounds like a good idea. Let’s do that!

No one ever…

I’m sure you’ve never seen this in practice, ever… Right? “Good idea” what!??? Of course, Alice is acting purely based on her project management targets, but she’s completely missing out on what automation should do for them. That is: reduce the intervention of humans in the process and get faster feedback from that regression suite (as opposed to people playing keyboard monkey roles).

What happened back there? Well, 40% of 10 tests is nothing, so bringing in 5 guys would be one person per test that could help, plus an extra one. The problem could be solved in a matter of minutes…? maybe hours…? Ok, good call Alice! But the elephant in the room, in this scenario, is that the regression suite is actually a joke, not real automation. I mean 10 tests? (yes, I just decided that there were 10 tests, don’t scroll up)

The problem in the large, is that the regression suite has hundreds, if not thousands of tests. This means feedback comes late (hours, sometimes days) after running the suite. Feedback is not clear most of the time, false positives, flakiness and all that good stuff.

Alright. QA automation. But there are other kinds, right? Oh, sure there are. Let’s take the famous “dev-ops pipelines” for example. “What does that even mean? Dev-ops pipeline, pfff…” you say? Stop pretending and just accept you already know what I’m talking about: pull source from GIT onto some jenkins machine, run some stuff from some other jenkins machine and then some stuff on this jenkins machine and then “promote” the buid to some other machine and chop a virgin’s head and make sure all is being pushed to Kibana and kill yourself.

Isn’t that THE automation? Well yes, it is, it’s just that… more often than not it goes wrong at different steps in the process. When was the last time you’ve seen a pipeline like this run well for weeks or months at a time, without human intervention. I can already hear the audience… “months? we’re stepping in a couple of times a day”.

We can see a pattern here: “automation” that hurts rather than helping. Ok, by “hurts” I mean that it does so for the business goals: late deliveries, buggy software products, etc. It totally does NOT hurt the persons which created the “automation” to begin with. To them, it’s an industry standard and if you’re not doing it, you’re so not worth living and must crawl with shame under your desk (not standing desk, of course, ’cause you’re a loser and you’re not trendy). They will defend it to their deaths (that is, until they move to a more trendy company, with bean bag chairs and super-expensive espresso machines).

There are many other kinds of “automation” which generate similar results to the above. Bottom line is: if it constantly hurts the business goals, you’re doing it wrong. “Yes, but my special case…”. NO! Just throw it away (YES, away it should go) and rethink the whole thing. I mean really rethink it in such a way that you won’t have to touch it afterwards, not just use a different technology to do the same thing all over again. It will probably take a bit of time (weeks, months…), but the business will certainly thank you for it.

P.S. What are you doing when you give business a software product? You automate its processes. Why? So they don’t have to do it manually. That business may just as well be you.