NICOLÁS BOTTARINI
Culti

Culti

Year:2018 - 2022
Industry:
AgTechStartup
Deliverables:
androidioswebbackend
Technologies:
JVMKotlinPostgreSQLJavalinjOOQReact NativeTypeScriptReactHeroku
Technical focus:
TDDContinuous IntegrationContinuous DeliveryPair ProgrammingDDDClean ArchitectureE2E TestsKanban

Introduction

Culti was an intelligent and collaborative agricultural platform that we developed from scratch as technology partners. It was a web and mobile solution that supported producers throughout the entire production cycle, facilitating planning, operational management, and commercialization, with a focus on data-driven decision-making.

From the beginning, we worked closely with the founding team, jointly carrying out the discovery process. From there, I took on the technical leadership: I designed the architecture from scratch, implemented the key components of the system, and trained the development team. Throughout the project, I applied technical excellence practices to build a solid, extensible, and scalable foundation.

What did Culti solve?

In agriculture, many producers still operate with Excel, isolated tools, or manual processes. This leads to:

  • Dispersed or outdated information
  • Difficulty making real-time decisions
  • Lack of financial planning
  • Inefficient management of tasks, inputs, and commercialization

Culti proposed a profound transformation, based on:

  • Valuable real-time information
  • Automation of operational and administrative processes
  • Contextual recommendations and personalized insights
  • Benchmarking and intelligent reports

Screens

Main features

Culti covered all stages of the production cycle with a practical approach, tailored to the real-world context of farming:

  • Campaign planning: Deciding what to plant, where, and with which practices. Automatic generation of task plans and input requirements. Agronomic plans recommended by specialized engineers.
  • Budgeting and cost control: Detailed expense estimation per field or crop, with tracking of actual deviations.
  • Task and operations management: Task assignment, execution tracking, and automatic field data entry.
  • Inventory: Control of inputs and harvested grains, with real-time stock management.
  • Commercialization: Price tracking, sales suggestions, and tools to reduce commercial risk.
  • Reports and recommendations: Standardized reports, comparisons with regional averages, and contextual system suggestions.

Reach and impact

Although the project did not succeed as a business, during its active phase it reached metrics that showed meaningful initial interest:

  • 4,500+ downloads
  • 1,400+ registered fields
  • 150,000+ hectares managed

The product aimed to enable better technical and commercial decisions, reduce costs, and free up operational time.

Screens

Technical approach

Culti consisted of a mobile application (Android and iOS) developed in React Native, a web application built with React, and an API developed on the JVM with Kotlin.

I chose Kotlin as the main backend language for its expressiveness, productivity, and balance between object-oriented and functional paradigms. It allows writing clear and declarative code, without Java's verbosity.

Libraries over frameworks

I avoid using frameworks, as they introduce a lot of indirection, unnecessary complexity, and take control over the application (it's the framework that ends up orchestrating it). Instead, I prefer lightweight libraries and a simple, explicit, and sustainable design.

For the web layer of the API, I used Javalin, a minimalist library that wraps Jetty — ideal for keeping tight control over request flow without hidden behaviors.

Persistence

For persistence, I ruled out the use of ORMs: they introduce over-engineering, obscure SQL, and promote an inadequate model for rich domains. Instead, we used jOOQ, a typed SQL DSL that allows full control over queries, integrating it with the Repository pattern to encapsulate data access in a clear and maintainable way.

ORMs usually map tables to data structures, not to rich domain objects. I prefer to explicitly separate layers and model the domain with intention.

We used PostgreSQL as the database and Flyway for schema versioning.

Explicit dependency injection

I don't use dependency injection containers. With a clear architecture, manual injection is simple and avoids unnecessary indirection. This improves system readability and makes execution flows easier to trace.

Auxiliary tools

I developed a CLI tool in JavaScript: a development console that automated frequent tasks and helped us improve productivity.

CI/CD and deployment

We used Heroku as our cloud platform for both staging and production environments. I implemented a continuous integration and deployment pipeline using GitLab CI. This pipeline:

  • Ran automated tests (unit, end-to-end, and integration) and performed code quality checks
  • Built versioned Docker images
  • Automatically deployed to Heroku
  • Executed pending Flyway migrations when necessary
CI/CD Pipeline
CI/CD Pipeline

Monorepo

We used a monorepo to group all system components: backend, web frontend, mobile, and internal tools. This greatly simplified local setup (done with a single command), favored synchronized versioning across components, and made full-stack development easier.

Monorepo

Architecture

Given the complexity of the domain and its specific rules, I decided to apply Domain-Driven Design and organize the system following the principles of Clean Architecture.

The benefits are multiple:

  • Clear separation of responsibilities
  • Independence from technological decisions (allowing the project to better withstand the passage of time)
  • Dependency direction managed from low-level details to high-level concepts
  • Easier testing and evolution
Architecture
API Architecture

Both the mobile and web applications communicated with the REST API, which acted as the delivery mechanism.

  • The web layer of the API contained lightweight controllers that interpreted requests and translated them into commands or queries.
  • These commands/queries were routed through a bus that invoked the corresponding handler (use case).
  • Use cases coordinated the execution of the necessary domain logic.
  • The domain held the core of the system: rich models, business rules, domain services, and event generation.

Use cases are responsible for implementing application logic (not business logic). They act as coordinators, triggering the necessary domain functionalities.

The domain models the business and its logic. The dependency inversion principle is used to implement services that require technological aspects (such as persistence, notifications, or external services). These implementations reside in the infrastructure layer. Since dependencies are inverted (infrastructure depends on the domain, not the other way around), it's very simple to change technological decisions — just update the application configuration to indicate the desired implementation.

Frontend architecture

In the frontend, business logic is more limited, but the rate of technological change is high. That's why I adopted a lighter version of Clean Architecture. The benefits are similar to those on the backend, but with even greater impact due to the rapid evolution of frontend tooling and the fragility of UI tests.

This structure allowed me to apply TDD effectively, reduce coupling, and maintain the team's agility in the face of change.

Frontend Architecture
Mobile App Architecture

I use React not as a framework but as a UI library. I rarely use hooks or state management libraries. View logic is encapsulated in Presenters: pure TypeScript classes with no dependency on React. Presenters call use cases to trigger business logic (local or remote via the API), format the data, and expose ViewModels containing the final, display-ready information.

This structure allows React to be replaced with another technology or version without rewriting the logic. It simplifies testing and decouples the app from the framework. For example, replacing React with Vue would only require swapping out the React layer — no Presenter, ViewModel, or App Service would need to be changed.

This makes the application more future-proof, allowing it to evolve alongside new technologies.

Domain modeling

Culti had a very rich domain model.

  • IDs were modeled as value objects (this explicitly represents the identity of an entity — something that persists through time and changes in characteristics).
  • The domain included a great deal of financial logic. We created value objects for business concepts such as costs, prices, amounts, and currencies. This allowed us to centralize logic related to those concepts, since Value Objects act as behavior attractors.
  • While I didn't implement CQRS in this project, for complex queries I opted to model reports instead of using repositories. A report was implemented in the infrastructure layer by executing a direct, complex SQL query.
  • I modeled domain events to reduce coupling in use cases and to make certain business policies more explicit.
class FieldFinancials(private val field: Field, private var grainId: GrainId?) {
  fun grossIncome(): Money {
    if (grainId == null) { return field.indicators.grossIncomePerHectare * field.hectares }
    return field.grainIndicators.single { it.grainId == grainId }.grossIncomePerHectare * field.hectares
  }
 
  fun marketingCost(): Cost {
    if (grainId == null) { return field.indicators.marketingCostPerHectare * field.hectares }
    return field.grainIndicators.single { it.grainId == grainId }.marketingCostPerHectare * field.hectares
  }
 
  fun indirectCost(): Cost {
    if (grainId == null) { return field.indicators.indirectCostPerHectare * field.hectares }
    return field.grainIndicators.single { it.grainId == grainId }.indirectCostPerHectare * field.hectares
  }
 
  fun hectares() = field.hectares
 
  fun isPlanned() = field.isPlanned()
 
  fun hasGrain(grainId: GrainId) = field.hasGrain(grainId)
 
  fun fieldId() = field.id
 
  fun scopedBy(grainId: GrainId) = FieldFinancials(field, grainId)
}

Testing strategy

Testing Strategy
Testing Strategy

In this project we applied TDD using a balance between Outside-In and Classic TDD.

The strategy was as follows:

  • We started with TDD on the presenter, defining the view logic for the feature we wanted to implement. If the presenter needed to invoke use cases, we mocked the bus.
  • Then we implemented the React component (the UI) using the presenter that had been developed.
  • Next, we applied TDD to the new commands or queries introduced by the presenter. We mocked the API clients (abstracted in the domain and implemented in the infrastructure layer).
  • Finally, we created a contract test to implement the API client (the class responsible for making the HTTP request to the API).
describe('CreateFarmPresenter should', () => {
    it('navigate to PlanEditor on success save', async () => {
        await presenter.start()
 
        await presenter.save(SOME_FARM_NAME)
 
        verify(viewMock.navigateToSelectFarmLocation(SOME_FARM_NAME)).called()
    });
 
    it('notify view that saving failed because the name is not valid', async () => {
        await presenter.start()
 
        await presenter.save(SOME_EMPTY_FARM_NAME)
 
        verify(viewMock.showError(anything())).called()
    })
})

Once the frontend was ready, we moved on to implementing the backend (our teams were cross-functional and full-stack):

  • We began with an end-to-end test that invoked a use case through the HTTP layer. These tests are similar to ATDD acceptance tests. The test would initially fail until all necessary functionality was implemented. We used RestAssured for these tests, which allowed us to easily execute and analyze HTTP requests. To build the scenario, we executed real commands from the application core. As we ran the test and encountered failures, it would guide us on what to implement next.
  • We applied TDD with sociable tests to the use cases (commands, queries, or event handlers). If the use case depended on infrastructure, we used test doubles (e.g., an in-memory repository). Most domain logic was tested indirectly from here, with concepts gradually emerging through refactoring.
  • In more complex cases, we applied TDD directly on domain objects to discover and validate behaviors that are hard to see from the outside.
  • Finally, we created integration tests for infrastructure components. For example, we tested real jOOQ repositories against a real database. We typically used transactions to roll back test data changes.

Example end-to-end test:

  @Test
  fun createField() {
    val request = CreateField.Request(aFieldName, someHectares, aPlanId, sessionToken)
    every { mediator.command<CreateField>().execute(request) } returns aFieldId
 
    Given {
      contentType("application/json")
      header("Authorization", "Bearer ${sessionToken}")
      jsonBody(
        JsonObject()
          .add("name", aFieldName)
          .add("hectares", someHectares.toInt())
          .add("planId", aPlanId.toInt())
      )
    } When {
        post(baseUrl() + "fields/create")
    } Then {
        isSuccessful()
        isJson()
        body("fieldId", equalTo(aFieldId.toInt()))
    }
  }

Example use case test:

  @Test
  fun `create each task with 'pending' status`() {
    val plan = scenario.plan { ownedBy(user) }
    scenario.field { plan(plan); program(scenario.simpleProgram()) }
 
    startProduction.execute(plan.id, sessionToken)
 
    with(generatedTask()) { assertThat(status).isEqualTo(TaskStatuses.Pending) }
  }

Example domain test:

  @Test
  fun `harvestCost is the sum of the labor cost of all harvest tasks`() {
    val task1 = scenario.task { harvest() }
    val task2 = scenario.task()
    val task3 = scenario.task { harvest() }
 
    val financials = PlanFinancials(tasksFinancials(task1, task2, task3), someFieldsFinancials)
 
    assertThat(financials.harvestCost()).isEqualTo(task1.laborCost() + task3.laborCost())
  }

To create scenarios in use case and domain tests, we used test data builders and test examples:

object GrainExamples {
  val grainNames = TestExamples("Maíz", "Trigo", "Cebada", "Arveja")
 
  fun aGrainId() = GrainId(85)
  fun otherGrainId() = GrainId(77)
}

To control time in tests, we modeled a clock abstraction (Clock). In tests we used a StoppedClock, which let us control time explicitly. In production code, we used a SystemClock.

Work methodology

I worked with cross-functional teams that included all the roles needed to build a product: Product Owner, graphic design, and full-stack developers. Developers were generalist-specialists: while some had deeper expertise in mobile, web, backend, or infrastructure, everyone could contribute across the entire codebase. The Product Owner also performed exploratory QA.

We worked daily using pair and mob programming. This ensured knowledge flowed continuously and helped everyone quickly understand all aspects of the system.

We practiced TDD and continuous integration. We didn't use feature branches in Git — we made micro-commits as part of the TDD cycle, and those were integrated directly into GitLab, where a pipeline ensured everything was in order.

If something failed on our only branch (main), the whole team prioritized fixing it. Main was always deployable. Every commit that passed the pipeline was automatically deployed to the staging environment, and stakeholders were notified so they could review the latest changes and give daily feedback.

For mobile, we used Firebase App Distribution so that each team member could always have the latest version on their phone.

Production deployment was done by integrating code into a special branch called "production". This process was fully automated.

Because we did continuous integration, micro-commits, and TDD, refactoring was part of our daily routine — it happened naturally in each TDD cycle. This helped keep technical debt under control and allowed the codebase to evolve as we learned more about the business.

We didn't use Scrum. Instead, we managed work with a Kanban board that included indicators to tell us when to meet and plan the next batch of user stories (on-demand planning).

Each column on the board had a WIP limit to prevent work accumulation and detect bottlenecks early. The workflow was optimized to minimize lead time and maximize throughput.

We didn't estimate. We sliced user stories so they took roughly 1 or 2 days to complete. To forecast, we tracked weekly throughput (i.e., how many stories reached "done" each week) and projected forward based on that.

We rarely did daily standups, since we already collaborated closely every day. Every two weeks we held a retrospective to improve the process and identify any problems.

For backlog management, we used user story mapping.