Clean code is the foundation of good software. Following its principles helps reduce technical debt, accelerate development cycles, and increase the long-term ROI of your software investment.
However, mobile development introduces unique constraints, including performance limitations and fragmented device ecosystems, that can make clean architecture harder to achieve in practice. And despite the popularity of SOLID principles, there’s still limited guidance on how to apply them effectively in mobile environments.
We couldn’t overlook that, as mobile app development is one of the things we do best at Syndicode.
This post breaks down the five SOLID design principles, adapted to the realities of mobile development, so you can better assess your team’s architectural decisions and build software that lasts.
We also offer dedicated development teams to help companies build high-performance mobile products faster and smarter.
What is clean code?
Clean code is a term often used in software development. At its core, it means code that is:
- Easy to read
- Easy to understand
- Easy to test
- Easy to maintain
In practice, clean code is not just about style or syntax; it’s about writing software that is resilient, adaptable, and scalable. It enables teams to work faster, reduce bugs, and evolve systems over time without adding risk.
To achieve clean code, developers rely on a set of well-established object-oriented design principles that guide how software should be structured. Among these are:
- Simplicity: Break complex problems into smaller, manageable parts. This makes the code easier to debug, extend, and maintain.
- Consistency: Use agreed-upon naming conventions, formatting styles, and structural patterns across the codebase. Consistency makes code easier to navigate, especially for teams and future contributors.
- Testability: Design code in a way that allows for easy and reliable testing, ideally through automation. Testable code catches issues early and supports continuous delivery.
- Maintainability: Ensure code can be updated or extended without introducing regressions. Maintainable code reduces long-term costs and improves team velocity.
This is where the SOLID principles come into play. SOLID provides a structured, object-oriented approach to clean code, offering five key principles that make software systems:
- Modular
- Reusable
- Robust
- Easier to evolve over time
Next, we’ll go through each of the SOLID principles in detail and see how they help produce better code.
SOLID principles explained
SOLID is a widely adopted set of object-oriented programming design principles that supports the goal of writing clean, maintainable, and scalable code. While originally introduced for general software development, SOLID is just as critical (if not more so) in mobile development, where fast iteration, modular architecture, and long-term maintainability are essential.
Each letter in the SOLID acronym stands for a principle that helps reduce complexity, improve testability, and guide developers in writing code that’s easier to maintain and extend. Below, we’ll unpack each principle with practical examples from mobile development to show how they work in real-world scenarios.
S – Single Responsibility Principle (SRP)
Each class should have one—and only one—reason to change.
When a class takes on multiple responsibilities, it becomes harder to test, debug, and extend. This is a common anti-pattern in mobile apps, where utility classes often end up doing too much.
Bad example: A single ImageManager class that handles uploading, resizing, loading from resources, and image transformations.
class ImageManager {
// If you have to name something 'Manager' - consider decomposition
public func uploadImage(_ image: UIImage) {
}
public func resizeImage(_ image: UIImage, size: CGSize) -> UIImage {
}
public func fromResource(resourceId: String) -> UIImage {
}
public func addAlphaChannel(_ image: UIImage) -> UIImage {
}
}
Currently, the `ImageManager` class has multiple unrelated responsibilities, such as uploading, resizing, retrieving, and adding an alpha channel to images. This design violates the Single Responsibility Principle and leads to tight coupling and poor modularity.
Better approach: Split responsibilities across focused components.
class ImageUploader {
public func uploadImage(_ image: UIImage) {
}
}
class ImageTools {
public func resizeImage(_ image: UIImage, size: CGSize) -> UIImage {
}
public func addAlphaChannel(_ image: UIImage) -> UIImage {
}
}
class ResourceBundle {
public func fromResource(resourceId: String) -> UIImage {
}
}
With this structure, changes to one class will not affect the others. Each class is easier to test, reuse, and maintain, which is a critical benefit in mobile projects with fast-changing requirements.
O – Open/Closed Principle (OCP)
Software entities should be open for extension but closed for modification.
As apps grow, you should be able to introduce new features or behavior without changing existing code. This reduces regression risk and keeps your codebase stable.
Bad example: Using switch statements to handle multiple transport protocols in one class.
class NetworkTransport {
enum Proto {
case TCP
case UDP
};
private let _proto : Proto;
init(_ proto: Proto) {
_proto = proto;
}
func sendMessage(message: String) {
switch (_proto) {
case .TCP: // Send over TCP
break
case .UDP: // Send over UDP
break
}
}
}
Adding a new protocol, such as WebSocket, would require modifying this class.
SOLID solution: Use polymorphism to extend functionality cleanly.
class NetworkTransport {
init() {
}
func sendMessage(message: String) {
}
}
class TCPTransport: NetworkTransport {
override init() {
super.init()
}
override func sendMessage(message: String) {
// Send over TCP
}
}
class UDPTransport: NetworkTransport {
override init() {
super.init()
}
override func sendMessage(message: String) {
// Send over UDP
}
}
// To add new transport you need a new class inherited from NetworkTransport
Now, adding a new transport method only requires adding a new class, not changing what’s already working.
L – Liskov Substitution Principle (LSP)
Subclasses should be replaceable for their base classes without altering correctness.
LSP ensures that a subclass can replace its parent class without breaking behavior or introducing subtle bugs.
Bad example:
class OrientationDevice {
init() {
}
func getOrientation() -> vector_float3 {
return simd_make_float3(0, 0, 0);
}
}
class Device1: OrientationDevice {
override init() {
super.init()
}
override func getOrientation() -> vector_float3 {
// Returns Euler angles
}
}
class Device2: OrientationDevice {
override init() {
super.init()
}
override func getOrientation() -> vector_float3 {
// Returns Roll/Pitch/Yaw
}
}
// You can not replace Device1 with Device2 and vice versa
// Because they return orientation in different formats
Here, both subclasses return different formats, making them inconsistent replacements for the parent class, which violates LSP.
Fix: Ensure all subclasses follow the same contract, both in signature and behavior. If needed, introduce separate abstractions for different orientation formats.
I – Interface Segregation Principle (ISP)
Clients should not be forced to depend on interfaces they do not use.
In mobile development, bloated delegates or protocols are a common ISP violation. They introduce unnecessary complexity, making code harder to maintain.
Bad example:
protocol FatUglyDelegate {
func onEntityCreated(_ entity: SomeEntity);
func onEntityUpdated(_ entity: SomeEntity);
func onEntityDestroyed(_ entity: SomeEntity);
func onUploadFileRequested(path: String);
func onUploadFileCancelled(path: String);
func onImagePicked(_ image: UIImage);
func onImagePickerError();
}
Better design: Split into smaller, purpose-specific interfaces. This results in cleaner, more focused abstractions, making the code easier to test and extend.
protocol SomeEntityCRUDDelegate {
func onEntityCreated(_ entity: SomeEntity);
func onEntityUpdated(_ entity: SomeEntity);
func onEntityDestroyed(_ entity: SomeEntity);
}
protocol FileUploadDelegate {
func onUploadFileRequested(path: String);
func onUploadFileCancelled(path: String);
}
protocol ImagePickerDelegate {
func onImagePicked(_ image: UIImage);
func onImagePickerError();
}
D – Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules. Both should depend on abstractions.
This principle encourages decoupling implementation details from core logic, a crucial practice in mobile architecture.
Bad example:
class Renderer {
enum API {
case None
case Metal
case OpenGL
};
init() {}
func getAPI() -> API { return .None }
func restoreContext() {}
func render() {}
}
class MetalRenderer: Renderer {
override init() {
super.init()
}
override func getAPI() -> API { return .Metal }
override func restoreContext() {}
override func render() {}
}
class OpenGLRenderer: Renderer {
override init() {
super.init()
}
override func getAPI() -> API { return .OpenGL }
override func restoreContext() {}
override func render() {}
}
func renderSomething(renderer: Renderer) {
if (renderer.getAPI() == .OpenGL) {
// This also breaks LSP because
// The user of Renderer has to alter behavior in order
// to renderer implementation
renderer.restoreContext();
}
renderer.render();
}
The rendering logic depends on specific implementations, breaking the Dependency Inversion Principle (and the Liskov Substitution Principle, too).
Fix: Move platform-specific logic into the renderer classes.
Validate your architecture before it breaks
Get a tailored architecture audit from Syndicode’s mobile experts and uncover bottlenecks, overengineering, and missed opportunities slowing down your app.
Book an auditSOLID vs DRY
The DRY (Don’t Repeat Yourself) principle also promotes clean code, but it operates at a different layer than SOLID. Its purpose is straightforward: avoid code duplication. The goal is to improve maintainability and reduce the risk of inconsistencies by centralizing logic that’s used in multiple places. It’s a tactical rule that helps keep codebases concise and easier to update.
For example, if the same business logic is copied across three different view controllers, DRY suggests refactoring it into a shared module. It’s about eliminating redundancy, and it’s valuable at every level of software development.
SOLID, on the other hand, is a set of principles designed to guide how classes and modules should be structured in object-oriented programming systems. Where DRY focuses on implementation details, SOLID shapes the overall architecture, affecting how easily a system can be extended, how quickly new developers can get up to speed, and how reliably features can be isolated and tested. SOLID principles don’t just clean up code; they help prevent structural issues as your software scales.
| Principle | Focus area | Primary benefit |
|---|---|---|
| DRY | Code-level implementation | Reduces duplication and inconsistency |
| SOLID | System-level architecture | Improves design, flexibility, and long-term maintainability |
Think of DRY as clean syntax and SOLID as clean structure.
SOLID vs KISS
The KISS (Keep It Simple, Stupid) principle prioritizes clarity and simplicity in both code and system design. The idea is that complexity is a liability: the simpler your solution, the fewer bugs you’ll introduce, and the easier it will be to understand, test, and maintain.
While KISS encourages you to avoid unnecessary complexity, SOLID provides the structure needed when complexity is unavoidable. These two principles complement each other, and in fact, teams often struggle when they apply one without the other. Overusing SOLID principles without KISS can lead to over-engineered code with excessive abstraction. Overusing KISS without SOLID can result in tightly coupled logic that’s difficult to extend or test.
In short, KISS helps you write less code, while SOLID helps you write better-structured code.
| Principle | Focus | Best for |
|---|---|---|
| KISS | Simplicity, minimalism | Reducing complexity, fast iteration |
| SOLID | Architecture, scalability | Structuring systems for growth and change |
SOLID design principles in mobile development
SOLID design principles remain highly relevant in mobile development, but their application tends to be more pragmatic and selectively enforced than in traditional desktop or web environments.
Mobile developers work under a unique set of constraints that often force trade-offs between architectural purity and real-world performance:
- Limited processing power and memory
Even modern mobile devices can’t compete with servers in terms of raw compute capacity. SOLID-compliant architectures often introduce additional layers of indirection, which can increase memory usage, trigger more frequent allocations, and cause garbage collection issues—especially on Android. - Battery life is a priority
Architectures that introduce extra background processing, frequent redraws, or inefficient data access patterns can drain battery life. Mobile developers often cut back on abstraction to minimize power consumption and stay within platform limits. - Storage limitations
Modular design can increase binary size through duplicated code paths, runtime reflection, and added dependencies. On mobile, this matters, as users actively uninstall large apps to free up space. - Strict performance expectations
Mobile apps are expected to be visibly responsive within 100–200ms, and maintain 60fps (or 120fps) for fluid UI. Clean architectures that add latency via event buses, excessive abstraction, or deep object hierarchies often fail to survive performance profiling.
In the next section, you’ll see two real-world use cases where violating SOLID principles leads to significant performance gains. These aren’t rare edge cases; they’re common in:
- Rendering pipelines
- Real-time video/image processing
- Audio streaming
- On-device ML inference
If your project touches any of these areas, keep reading.
When NOT to use SOLID on mobile
SOLID principles give structure, clarity, and long-term maintainability to software architecture. But mobile development often faces strict performance, memory, or latency constraints. This is especially true in graphics-heavy or real-time scenarios. In these cases, following SOLID strictly can come at a cost.
Below, we’ll walk through a real-world example where breaking OCP, DIP, and encapsulation resulted in 2–3 times performance gains, and then show why these trade-offs were necessary and acceptable.
Use case 1: Calculating shape areas in a 2D world
Let’s say we’re building a feature that evaluates the combined area of different shapes (points, circles, rectangles) on a 2D plane, only for shapes within a specified viewport.
Step 1: Clean, SOLID-compliant design (baseline)
We’ll begin with an abstract base class Figure, and three subclasses that implement shape-specific logic. This respects OCP and LSP, allowing extension via polymorphism.
class Figure
{
public:
Figure() {}
virtual ~Figure() {}
virtual bool isInside(const Rect2f& area) const = 0;
virtual float square() const = 0;
float squareIfInside(const Rect2f& area);
private:
};
Here’s a sample hierarchy of subclasses for different types of figures that can be placed on a 2D plane:
class Point: public Figure
{
public:
Point() {}
Point(const Rect2f& area);
~Point() {}
bool isInside(const Rect2f& area) const override
{
return common::Evaluate::isPointInArea(_pos, area);
}
float square() const override
{
return common::Evaluate::pointSquare(_pos);
}
private:
Vec2f _pos;
};
class Circle: public Figure
{
public:
Circle(): _radius(0) {}
Circle(const Rect2f& area);
~Circle() {}
bool isInside(const Rect2f& area) const override
{
return common::Evaluate::isCircleInArea(_center, _radius, area);
}
float square() const override
{
return common::Evaluate::circleSquare(_center, _radius);
}
private:
Vec2f _center;
float _radius;
};
class Rectangle: public Figure
{
public:
Rectangle() {}
Rectangle(const Rect2f& area);
~Rectangle() {}
bool isInside(const Rect2f& area) const override
{
return common::Evaluate::isRectangleInArea(_pos, _size, area);
}
float square() const override
{
return common::Evaluate::rectangleSquare(_pos, _size);
}
private:
Vec2f _pos;
Vec2f _size;
};
The World object holds a collection of shapes:
void World::initialize()
{
srand(100500);
int objectsCount = (int)_figures.size();
for (int i = 0; i < objectsCount; ++i)
{
if (_figures[i] != nullptr)
delete _figures[i];
_figures[i] = createRandomFigure(_allArea);
}
// Shuffle to simulate non-sequential objects creation
for (int i = 0; i < objectsCount; ++i)
{
int i0 = rand() % objectsCount;
int i1 = rand() % objectsCount;
if (i0 != i1)
{
Figure* figure = _figures[i0];
_figures[i0] = _figures[i1];
_figures[i1] = figure;
}
}
}
double World::benchmark(int aCount, long long& oResult)
{
using namespace std::chrono;
auto start = high_resolution_clock::now();
double square = 0.0;
for (int c = 0; c < aCount; ++c)
{
for (auto & f : _figures)
square += f->squareIfInside(_viewArea);
}
oResult = (long long)(square / aCount);
auto stop = high_resolution_clock::now();
auto duration = duration_cast<microseconds>(stop - start);
return 0.001 * duration.count() / aCount;
}
The following code fragment runs the benchmark.
void run_shapes_test()
{
printf("Running Shapes Test\n");
static const int kObjectsCount = 100000;
static const int kIterationsCount = 1000;
double ref = 0.0;
{
s1::World world(kObjectsCount);
world.initialize();
long long result = 0;
double ms = world.benchmark(kIterationsCount, result);
ref = ms;
printf("V1 T = %10.6f ms/pass | Boost: %.2f | %lld\n", ms, ref / ms, result);
}
}
Benchmark result, V1 (Baseline):
1.583 ms/pass | Boost: 1.00x | Result: 5,864,078
Step 2: Break OCP and replace virtual methods with a switch
Instead of relying on virtual methods, we switch to a type enum and use switch statements inside Figure.
class Figure
{
public:
enum class Type
{
Point = 0,
Circle,
Rectangle,
};
public:
Figure(Type aType): _type(aType) {}
virtual ~Figure() {}
float squareIfInside(const Rect2f& area);
private:
Type _type;
};
// Inherited classes remain intact except 'override' keyword
We define a templated helper for each shape:
template <Figure::Type FT>
static float impl_squareIfInside(const Figure* f, const Rect2f& area)
{
typedef typename Cast<FT>::FigureType FigureType;
const FigureType* ptr = (const FigureType*)f;
return ptr->isInside(area) ? ptr->square() : 0.0f;
}
Then dispatch using a switch:
float Figure::squareIfInside(const Rect2f& area)
{
switch (_type)
{
case Type::Point:
return impl_squareIfInside<Type::Point>(this, area);
case Type::Circle:
return impl_squareIfInside<Type::Circle>(this, area);
case Type::Rectangle:
return impl_squareIfInside<Type::Rectangle>(this, area);
}
}
Benchmark result, V2:
1.133 ms/pass | Boost: 1.40x
Why the speedup? The compiler now inlines all evaluation functions, eliminating virtual calls and enabling more aggressive optimizations.
Step 3: Break DIP and remove inheritance entirely
Now, we’ll consolidate all shape data into a single, flat structure with a common size and switch logic, maximizing memory locality and eliminating dynamic allocation.
class Figure
{
public:
enum class Type
{
Point = 0,
Circle,
Rectangle,
};
public:
Figure();
Figure(Type aType, const Rect2f area);
~Figure() {}
float squareIfInside(const Rect2f& area)
{
switch (_type)
{
case Type::Point:
return common::Evaluate::isPointInArea(_ver[0], area) ? common::Evaluate::pointSquare(_ver[0]) : 0.0f;
case Type::Circle:
return common::Evaluate::isCircleInArea(_ver[0], _ver[1].x, area) ? common::Evaluate::circleSquare(_ver[0], _ver[1].x) : 0.0f;
case Type::Rectangle:
return common::Evaluate::isRectangleInArea(_ver[0], _ver[1], area) ? common::Evaluate::rectangleSquare(_ver[0], _ver[1]) : 0.0f;
}
}
private:
Type _type;
Vec2f _ver[2];
};
Benchmark result, V3:
1.016 ms/pass | Boost: 1.56x
We’ve now fully eliminated virtual dispatch, static casting, and heap allocation. We’re writing very “unclean” but very fast code.
Step 4: Break encapsulation with external allocator and type-specific iteration
Finally, we optimize for memory layout and type-specific access. The Figure class becomes a lightweight descriptor, and an external allocator stores shape data.
class Figure
{
public:
enum class Type
{
Point = 0,
Circle,
Rectangle,
};
public:
Figure();
Figure(Type aType, const Rect2f area, FigureDataAllocator* allocator);
~Figure() {}
private:
Type _type;
int _id;
};
The initialization code remains the same; however, the `World` class now owns all the figures’ data through its ownership of the `FigureDataAllocator`. The `FigureDataAllocator` allocates only the necessary amount of memory for each figure and has internal plain data storage for each figure type.
This means that the `World` can iterate through particular types of figures one by one without needing to use casts, switches, or other memory overheads.
Benchmark result, V4:
0.891 ms/pass | Boost: 1.78x
Step 5: Minor optimizations
We’ll remove points entirely (their area is always zero) and optimize math (e.g., multiply by π only once).
Benchmark result, V5:
0.654 ms/pass | Boost: 2.42x
Performance summary
| Version | Architecture | Time (ms/pass) | Speedup |
|---|---|---|---|
| V1 | SOLID OOP | 1.583 | 1.00× |
| V2 | Polymorphism → Switch | 1.133 | 1.40× |
| V3 | No inheritance | 1.016 | 1.56× |
| V4 | Flat memory layout | 0.891 | 1.78× |
| V5 | Fully optimized | 0.654 | 2.42× |
Impressive, isn’t it? By breaking OCP, DIP, and encapsulation, we gained:
- Simpler memory layout
- Fewer cache misses
- Full inlining
- No virtual calls or heap fragmentation
But we sacrificed:
- Reusability
- Extensibility
- Encapsulation
- Testability
In most mobile apps, these trade-offs would be unacceptable. But in performance-critical systems, like rendering pipelines, image filters, or signal processing, they can be worth it.
Sometimes, performance matters more than architectural purity.
Need a team that gets performance and architecture?
Our dedicated mobile teams combine clean code with real-world efficiency, so you don’t have to compromise.
Learn moreUse case 2: Reading and writing pixels in a 4-channel image
In mobile development, working with large images or real-time graphics often demands low-level memory optimizations. In such cases, following SOLID—and specifically DIP—can introduce overhead that hurts performance.
Here’s a practical example from image processing that shows how breaking clean code rules can result in a 50x performance boost.
We have a simple Image class that holds a 4-channel image and provides safe accessors to read and write pixels. This design protects against out-of-bounds access and adheres to encapsulation and DIP.
class Image
{
public:
Image(int aWidth, int aHeight);
~Image();
inline simd::uchar4 getPixel(int x, int y) const
{
if (x < 0 || y < 0 || x >= _width || y >= _height)
return simd::uchar4();
return getPixel_unsafe(x, y);
}
inline void setPixel(int x, int y, const simd::uchar4& aPixel)
{
if (x < 0 || y < 0 || x >= _width || y >= _height)
return;
setPixel_unsafe(x, y, aPixel);
}
inline simd::uchar4 getPixel_unsafe(int x, int y) const
{
return _data[y * _width + x];
}
inline void setPixel_unsafe(int x, int y, const simd::uchar4& aPixel)
{
_data[y * _width + x] = aPixel;
}
private:
std::shared_ptr<std::vector<simd::uchar4>> _holder;
simd::uchar4* _data;
int _width;
int _height;
};
The safe accessors validate input. The unsafe ones assume the caller knows what they’re doing. SOLID would tell us to hide these unsafe methods or abstract them away via a contract.
But what happens when we prioritize performance over safety?
Step 1: Benchmark safe vs. unsafe access
We’ll run a benchmark that creates a large image (4096×3072), fills it with values, and XORs all pixel values using both safe and unsafe accessors.
Safe accessor:
{
auto start = high_resolution_clock::now();
uint32_t check = 0;
for (int i = 0; i < kIterations; ++i)
{
for (int x = 0; x < kWidth; ++x)
{
for (int y = 0; y < kHeight; ++y)
{
simd::uchar4 p = image.getPixel(x, y);
uint32_t* pp = (uint32_t*)&p;
check ^= *pp;
}
}
}
auto stop = high_resolution_clock::now();
auto duration = duration_cast<microseconds>(stop - start);
double ms = 0.001 * duration.count() / kIterations;
ref = ms;
printf("V1 T: %10.6f ms/pass | B: %8.2f | %lx\n", ms, ref / ms, (unsigned long)check);
}
Unsafe accessor:
{
auto start = high_resolution_clock::now();
uint32_t check = 0;
for (int i = 0; i < kIterations; ++i)
{
for (int x = 0; x < kWidth; ++x)
{
for (int y = 0; y < kHeight; ++y)
{
simd::uchar4 p = image.getPixel_unsafe(x, y);
uint32_t* pp = (uint32_t*)&p;
check ^= *pp;
}
}
}
auto stop = high_resolution_clock::now();
auto duration = duration_cast<microseconds>(stop - start);
double ms = 0.001 * duration.count() / kIterations;
printf("V2 T: %10.6f ms/pass | B: %8.2f | %lx\n", ms, ref / ms, (unsigned long)check);
}
Benchmark results:
| Version | Method | Time per pass | Speedup |
|---|---|---|---|
| V1 | getPixel (safe) | 71.18 | 1.00× |
| V2 | getPixel_unsafe | 38.42 | 1.85× |
The safety checks introduce significant overhead, nearly doubling the execution time.
Step 2: Cache optimization—loop order matters
Now, what if we optimize the loop traversal order?
Original loop:
for (int x = 0; x < kWidth; ++x)
{
for (int y = 0; y < kHeight; ++y)
{
simd::uchar4 p = image.getPixel_unsafe(x, y);
Reordered loop:
for (int y = 0; y < kHeight; ++y)
{
for (int x = 0; x < kWidth; ++x)
{
simd::uchar4 p = image.getPixel_unsafe(x, y);
Accessing memory row by row (sequential memory) leverages CPU cache. The original loop results in large, repeated memory jumps, causing cache misses and pipeline stalls.
Benchmark result, V3:
1.33 ms/pass | Boost: 53.23×
That’s a 53× speedup—not from multi-threading or hardware acceleration, but from loop order and cache-friendly memory access.
So, what happened is we broke SOLID:
- DIP is violated: Client code is responsible for ensuring safe access.
- Encapsulation is broken: Low-level details (data layout, index validation) leak into consuming code.
- LSP risks: getPixel_unsafe is not a true substitute for getPixel; it behaves differently under invalid input.
Yet for real-time applications like camera filters, image editing, or on-device ML preprocessing, the performance gain is well worth it.
You could ship a perfectly SOLID-compliant image class. But on mobile devices, where:
- You process hundreds of megapixels per second
- Power and memory budgets are tight
- User interaction must stay under 16 ms/frame
…those clean abstractions can become bottlenecks.
Sometimes, knowing when to break the rules is what separates a fast app from a sluggish one.
If you’re working with real-time graphics, computer vision, or image processing on mobile, you’ll also want to read our deep dive into computer vision on mobile.
Summary: Clean code is not a dogma
The examples in this post are not meant to undermine the value of clean code principles, SOLID, or other widely accepted coding practices. In most projects, these guidelines play a critical role in building reliable, maintainable, and scalable software.
However, the goal here was to show that there are situations where it’s necessary to step back from conventional clean code ideals to deliver a better product.
It’s not uncommon to encounter dogmatic claims that certain paradigms, such as OOP or SOLID, are the only correct approach. In reality, these conventions are tools, not rules. They should be applied strategically, not blindly.
Misusing or overapplying them can lead to overengineering, performance bottlenecks, unnecessary power consumption, or longer development cycles. The best engineers and teams know when to prioritize architecture and when to prioritize execution.
Clean code matters. But clean execution can matter more.
Frequently asked questions
-
Will breaking SOLID or other principles make my code harder to maintain?
It can, if not done thoughtfully. That’s why it’s essential to isolate these decisions, document them, and use them only where they provide real, tested value. Balance is key.
-
How can I tell if my architecture is overengineered?
Signs include slow iteration speed, excessive boilerplate, poor performance on mid-tier devices, or difficulty onboarding new developers. A professional architecture audit can help identify unnecessary complexity or bottlenecks.
-
Do your dedicated teams follow clean code practices?
Yes, we build mobile teams that understand both clean architecture and the practical demands of performance. Our approach is balanced, not dogmatic, and tailored to your app’s goals