Tuesday 12 December 2023

Mobile application buffet

I refer to this so often it's now a blog post, enjoy


Parts common to a mobile application

  • Dependency injection
  • Navigation / deep linking
  • State management (component / application)
  • Themes and styles
  • Animations and transitions
  • Accessibility (a11y)
  • Internationalisation (i18n)
  • Threading
  • Database storage (local)
  • Preference storage
  • Secure storage
  • API endpoints (external)
  • API policy - auth, retry, error policy
  • Analytics
  • Logging
  • Testing
  • Inter-layer communication
Project level considerations
  • Build and deployment
  • Continuous integration
  • Monitoring (metrics)
  • Code style
  • Architectural patterns
  • Implementation patterns
  • Definition of done
  • Source control
  • Issue tracking (e.g. Jira)
  • QA process
  • Documentation
  • Compliance
  • Modules

Tuesday 24 October 2023

Modularisation

Application modularisation has benefits in both architecture and developer performance. It also will add some new problems to solve, and pitfalls to avoid.

Encapsulation

We separate our code by namespaces, package names and or folders. Hierarchies are built to represent the different layers of our application and provide visual and logical distinction. With the basic scopes that can apply to variables, functions and classes we can set an element to be part of the private implementation details of the class, or public to the rest of the application.

Moving code into a module allows a new scope to be applied that marks an element public to just the module, but not the entire application.

Consider the following example:

As an application grows the need to create and enforce separation between layers grows. Consider the following layered example:

Service layer > Repository layer > Business logic layer > UI layer

The service layer contains specialised code to talk to external services like API endpoints and local database.

The repository layer will request and receive data from different services in the service layer, and expose domain models for consumption.

The business logic layer will consume the repository layer data and modify it as necessary to meet the business needs of the application. Here the intentions become more oriented around user tasks, rather than talking with different external processes like databases and API endpoints.

The UI layer takes the output of the business logic layer and presents it to the user, passing back user actions and inputs to the business logic layer for further mutation.

The above example defines clear boundaries and communication paths between layers. In the original model, any code marked public is available to the entire application. Allowing large teams moving quickly to easily break the layered model and incur technical debt, bugs and wasted time. Using well designed modules enforces good code separation, and prevents leaking of library specific code into other modules that do not reference that library. All caught at compile time.

Readability

Developers will spend much more of their time reading and understanding code, than writing it. When writing code, spend some extra cycles ensuring it can be read and understood quickly by a fellow developer. The code will be written once, but then read and maintained many more times.

Code separated into visually distinct and well documented and understood modules will help a developer understand the overall structure of an application quickly, and help them find the parts they are interested in.

Build times

Modern build systems are much more clever than the old brute force approach of 'rebuild entire application for every development code change'. Commonly the line is drawn at rebuilding the entire module for each code change. If all the code is in one single module, then much more rebuilding, code generation, packaging and deployment steps are needed for each code change. Some libraries can also add many more steps to the build process, generally code written with these libraries tends to be more stable, and can be confined to a module not often modified.

Migrating to modules ensures that only the most minimal parts of the application are rebuilt, saving developers time that would have been wasted on each and every build. (Which we do a lot of if you're a non developer reading this)

Design

Designing which modules to create, and how they interact and depend on each other is just as much an art as it is a science. Every code base is different, but here is the approach I tend to prefer.

For really small projects that are ephemeral I use a single module. Don't over architect when you don't need it.

For medium size projects, or small projects that need to be worked on for a longer time I create minimal modules to separate my layers and encapsulate implementations and libraries. One module for each layer mentioned in the earlier example provides this, it won't scale with the number of features I add, and the temptation is always there to take shortcuts, but overall the positive effects are worth the effort.

For large and extremely long term projects I prefer to build a hybrid module system. Feature modules will work alongside library encapsulation layer modules such as API and Database. This also decouples a feature from the lower repository layer.

Communication

For my designs, communication between modules is usually done via an indifferent mediator like local database that can be observed (rather than pinged for updates), and common navigation destination names can be shared throughout the application, with the actual implementation tucked safely away inside a module. Feature module A, can try to navigate to Feature module B by asking the core components to navigate to the concept of Feature B, and core will then call Feature B directly. This does require the core to be aware of all feature modules, but that is already required by advanced dependency injection systems like Android Hilt.

Balance

There is a non zero amount of overhead associated with each module, and this will build up over time in a tragedy of the commons / death by a thousands cuts kind of way. There may be extra mapping required to more common models in order to share data. There will certainly be extra steps to trace issues and longer call stacks when the number of modules increases in a project. 

Balance must be struck when deciding on a module pattern to use. Split by features and it will scale with the app well (no single module will get too big), but all the layers are now split across each feature and you lose the encapsulation. Split by layer, and features become spread out across all modules. The modules will continue to grow in size and complexity along with the application and become unwieldy. My advice is to brainstorm with the team and try out different ideas to see how they feel to code, maintain and understand.

Conclusion

Modularisation is a beneficial practice to any code base. It is best applied at the beginning of a project, but it can be a beneficial exercise to an existing mature code base as well. It forces the frontend developer who is normally used to the mindset of implement the minimum code needed for my current task, to take a step back and consider the application as a whole. The benefits will almost always out weigh the investment needed to begin using multiple modules.

Saturday 7 October 2023

Unit Testing in Android

Unit testing is writing code to test a small, isolated unit of application code. Given a certain input, determine the expected return value, and or public state is updated. In Android it's important for general reasons along with increasing the iteration speed of seeing results from code written, without going through long build times, or navigating to specific flows within an application.


Well tested code ensures new code doesn't break old functionality, and that the new code works to requirements. It gives confidence to refactoring in the future, and saves time for teams in bugs found, logged, fixed and retested. It means less bugs and crashes found in production. Done really well it will increase development velocity.


Tests should be quick to write, fast running, repeatable and readable. They should provide more value than the time it took to write and maintain them, and help the developer decide on code structure and flow to maintain simplicity and serviceability. Build your team and code base to the point where they are comfortable not asking for extra time in estimations to add them.


Test parts of the code that give the best return for the time invested. Asserting if a function calls a particular method on a mocked class is mostly useless. Testing complex logic with many control returns correctly for different input edge cases is much more worth the time investment. I don't recommend aiming for a coverage percentage, this encourages useless tests that waste time and effort.


Test for the outcome rather than the implementation. Imagine your class under test as a black box. With known inputs and expected outputs and public state. If the class does not have some kind of input, or public output what does it actually do? If you test implementation it will become a maintenance burden.


Refactoring code should require minimal modification to unit tests, if the requirements remained the same. This makes the test code robust, and require less maintenance. If you're testing the implementation, a refactor might mean rewriting the tests entirely.


Unit tests can help structure the architecture and choose which libraries to use, but I won't expose internal state just for tests, this falls into testing implementation. Something like @VisibleForTesting should be a last resort, and viewed as tech debt to be later addressed.


Tests should be instantly readable and as simple as possible, this makes maintenance is easier. A test failure should give hints. Follow patterns such as "given, when, then", and names that will help determine what is broken. Iterate several times over your important tests at first, and over time speed and simplicity should improve.


Don't test what you don't own. Assume the database library will return the data you've requested. Just test for the correct return values for the given inputs, along with the unhappy paths of any errors or exceptions the classes can return for given situations, such as an API becoming unreachable.


In Android I tend to leave the Framework out of the test as much as possible. I'll go as close to the view layer as testing ViewModels. If you're doing things with Context (like resolving strings, or calling startIntent), I normally wrap that functionality in classes to abstract away that detail to the ViewModel, and make testing easier as well.


Fake or Mock dependencies, the more tests you write the better your judgement on which to use. Each lends itself better to different situations. A good Fake can save time and simplify tests, but a Mock is often quicker and more straight forward. If unsure, begin with using Mock for everything, and then meditate on whether a Fake could improve the harder to read tests.


Some integration tests in between UI and external dependencies can be helpful, if possible. They're harder to pull off, but give a greater indication of the whole feature working together cohesively, and covers testing the boring parts without having to write specific tests for them. Think of it as the last safety net. Don't waste too much time achieving this if the codebase is not cooperative.


If starting a new code base, it is easiest to bring in unit testing and agreed on standards from the start. The architecture and code flow will benefit from a developer considering good unit testing strategies while designing.


If adding tests to an old code base with no tests. Agree on a standard and start small. Get some very simple tests working and running in CI. Then begin with training and humble requirements to test selectively. Make it part of the culture to request one or two useful tests at code review. Some refactoring may be required to make the code more testable. Break this work up into smaller tasks and iterate towards the team's unit testing end goals.


If improving on a code base with a large number of failing or flaky tests, it's alright to commit 'unit test bankruptcy', and disable all the tests that need fixing (they're not providing value at the moment any way), then begin the iterating towards adding new useful, stable tests, and eventually fixing or replacing the flaky and broken.


Lastly, don't let the client or product owner determine if there will be unit testing in the code or not. You don't tell your doctor whether or not they have time to wash their hands. We are the experts in this field, and we will determine what is needed as part of our responsibility to write robust, elegant code for our users.