NICOLÁS BOTTARINI
Portal Expensas

Portal Expensas

Year:2021 - Present
Industry:
PropTechStartup
Deliverables:
androidioswebbackend
Technologies:
KotlinPostgreSQLJavalinjOOQReact NativeTypeScriptReactAWSECSTerraform
Technical focus:
TDDContinuous IntegrationContinuous DeliveryPair ProgrammingDDDClean ArchitectureE2E TestsKanban

Introduction

Portal Expensas is a digital platform (web and mobile) for condominium management and expense administration in residential buildings. The solution was developed to modernize a system with a track record of over 20 years in the PropTech sector, currently used by administrators managing more than 200,000 housing units.

Over time, the original software accumulated valuable features, but also significant technical debt that hindered the company's ability to evolve. At 404 Crafters, we supported their digital transformation. In this project, I led the technical team, designed the overall architecture, and developed several of the key components. We built a new experience for both owners and tenants, focused on simplicity, autonomy, and effective communication with the administration.

The new system allows users to check their account balance, make payments, download receipts, access previous statements and documents, submit claims, book amenities, participate in surveys, and receive important communications from the management team.

Additionally, I developed an integration API that connects this new layer with the legacy core system, enabling progressive modernization without disrupting operations and paving the way for new services across the condominium ecosystem.

Today, more than 50,000 users interact with the app every month — and that number keeps growing.

Screens

Technical approach

Portal Expensas consists of a mobile application (Android and iOS) built with React Native, a web application developed with React, and an API built on the JVM using 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, declarative code without the verbosity of Java.

Libraries over frameworks

I avoid using frameworks, as they introduce unnecessary indirection and complexity, and take control away from the application (the framework ends up controlling it). Instead, I prefer lightweight libraries and a simple, explicit, and sustainable design.

For the API's web layer, I used Javalin, a minimalist library built on top of Jetty, ideal for keeping full control over the request flow without hidden magic.

Persistence

For persistence, I deliberately avoided ORMs. They tend to introduce over-engineering, hide SQL, and lead to a poor fit for rich domain models. Instead, we used jOOQ, a type-safe SQL DSL that gives full control over queries while integrating cleanly with the Repository pattern to encapsulate data access in a clear and maintainable way.

ORMs typically map tables to data structures rather than rich domain objects. I prefer to explicitly separate layers and design the domain with clear intent.

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

Explicit dependency injection

I don't use dependency injection containers. With a clear architecture, manual dependency injection is straightforward and minimizes indirection. This improves system readability and makes execution flows easier to follow.

Auxiliary tools

I developed a CLI tool in JavaScript: a developer console that automates frequent tasks and helps improve team productivity. I also created PopulateDB, a utility for initializing the development database with different scenarios and contexts, making it easier to develop and validate features across multiple use cases.

CI/CD and deployment

We used AWS as the cloud platform, with ECS for container orchestration, ECR for Docker image storage, and RDS as the database engine. We automated the full provisioning of staging and production environments using Terraform, including service configuration, networking (VPC), firewall rules, and dependencies.

The CI/CD pipeline was implemented with GitLab CI and includes:

  • Automated execution of unit, integration, and end-to-end tests
  • Versioned Docker image builds and uploads to ECR
  • Automated deployment to ECS
  • Execution of pending migrations using Flyway
  • Full staging provisioning on every infrastructure change
  • Manual approval step for production, with Terraform diff preview before applying changes
CI/CD Pipeline
CI/CD Pipeline

I also designed a centralized feature flag system that allows enabling or disabling features in real time, both from the backend and from the mobile and web applications. This made it possible to decouple deployment from release, reducing risk and enabling gradual rollouts.

Monorepo

We used a monorepo to group all system components: backend, web frontend, mobile app, and internal tools. This greatly simplified the local setup (which can be launched with a single command), enabled synchronized versioning across components, and facilitated full-stack development.

Thanks to the frontend architecture —described later— we were able to reuse around 80% of the code between the web and mobile versions, significantly reducing duplication, speeding up development, and maintaining functional consistency across platforms.

Monorepo

Architecture

I applied Domain-Driven Design and organized the system following the principles of Clean Architecture. This approach brings multiple benefits:

  • 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 toward high-level concepts
  • Ease of testing and evolution
Architecture
API Architecture

Both the mobile and web applications communicate with the system via a REST API, which acts as the delivery mechanism.

  • The API's web layer contains lightweight controllers that interpret incoming requests and translate them into commands or queries.
  • These commands/queries are routed through a bus that invokes the corresponding handler (use case).
  • Use cases coordinate the execution of the required domain logic.
  • The domain layer is the heart of the system: it contains rich models, business rules, domain services, and event generation.

Use cases are responsible for implementing application logic (not business logic). They act as coordinators that trigger the necessary domain operations.

The domain models the business and its rules. It uses the Dependency Inversion Principle to handle concerns that require technological implementation (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 easy to swap out technologies by simply adjusting the application configuration to point to a different implementation.

Frontend architecture

On the frontend, business logic is limited, but the pace of technological change is high. For that reason, I adopted a lightweight adaptation of Clean Architecture. The benefits are similar to those in the backend, but in the frontend they have even greater impact due to the instability of UI technologies and the fragility of frontend testing.

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

Frontend Architecture
Mobile App Architecture

I use React not as a framework, but as a UI library. I barely use hooks or state management libraries. Instead, view logic is encapsulated in Presenters: pure TypeScript classes with no dependency on React.

Presenters invoke use cases to activate business logic (which may be local or come from the API), format the data, and expose ViewModels with display-ready information.

This structure makes it possible to replace React with another technology or version without rewriting any logic. It simplifies testing and decouples the app from the framework. Replacing React with Vue, for example, would only require changing the React layer — no Presenter, ViewModel, or App Service would need to be modified.

This enables the application to evolve and adapt to new technologies over time.

In Portal Expensas, we leveraged this architecture to share around 80% of the codebase between the mobile and web versions. All core logic, app services, and presenters were reused across both platforms, maintaining a single source of truth for most of the behavior.

The structure is flexible enough to allow platform-specific presenters when certain screens require significantly different implementations. This strategy brought clear benefits: increased productivity, reduced code duplication, and much simpler system maintenance and evolution.

Shared frontend code

Domain modeling

Portal Expensas features a rich and expressive domain model.

  • Identities modeled as value objects: Each entity has its identifier explicitly represented as a value object, reinforcing the concept of a persistent identity over time, even as attributes change.
  • Domain events: I used domain events to decouple use cases and explicitly represent certain business policies. This brought greater clarity to the flows and improved extensibility for future scenarios.
  • Simplified CQRS: I implemented a lightweight version of CQRS. In the application layer, simple queries access the domain directly through repositories. For more complex queries, we query the database directly using jOOQ, accessing views built on top of the write model tables. For these cases, we modeled dedicated read models, designed to return exactly the data each query requires, without forcing unnecessary loading of the domain model.

Value Object example:

class Address(
    val street: String,
    val city: String,
    val countryCode: CountryCode,
    val zipCode: String? = null,
) {
    init {
        street.ifBlank { throw RequiredArgumentError("street") }
        city.ifBlank { throw RequiredArgumentError("city") }
        zipCode?.ifBlank { throw ArgumentCannotBeEmptyError("zipCode") }
    }
 
    override fun equals(other: Any?) =
        other is Address &&
        other.street == street &&
        other.city == city &&
        other.countryCode == countryCode &&
        other.zipCode == zipCode
 
    override fun hashCode(): Int {
        var result = street.hashCode()
        result = 31 * result + city.hashCode()
        result = 31 * result + countryCode.hashCode()
        result = 31 * result + (zipCode?.hashCode() ?: 0)
        return result
    }
 
    override fun toString(): String {
        return "Address($street, $city, $countryCode, $zipCode)"
    }
}

Event use case example:

class OnNewAmenityCreateAmenityBookingConfig(private val amenityBookingConfigs: AmenityBookingConfigs): EventHandler {
    override val eventTypes = listOf(AmenityCreated::class)
 
    override fun onEvent(event: Event) {
        if (event is AmenityCreated) { onAmenityCreated(event) }
    }
 
    private fun onAmenityCreated(event: AmenityCreated) {
        val bookingConfig = AmenityBookingConfig.createDefaultFor(event.amenity)
        amenityBookingConfigs.add(bookingConfig)
    }
}

Use case example:

@RequiredFeatures([CLAIMS])
@Authorization([Admin])
data class CloseClaim(val claimId: Id<Claim>): PureCommand {
 
    internal class Handler(private val repositories: RepositoryProvider): RequestHandler<CloseClaim, Unit> {
        override fun execute(request: CloseClaim, identity: Identity) {
            val claim = repositories.claims().get(request.claimId)
            claim.close()
            repositories.claims().update(claim)
        }
    }
}

In this last example, you can see how authorization is implemented using annotations — in this case, the use case can only be executed by users with the administrator role. Additionally, there's a feature flag annotation: the use case can only be executed if the claims functionality is enabled.

Testing strategy

Testing Strategy
Testing Strategy

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

The strategy follows this sequence:

  • Start with TDD on the Presenter, defining the view logic for the functionality being developed. If the presenter needs to invoke use cases, a mock of the bus is used.
  • Then implement the React component (the UI layout) using the developed presenter.
  • After that, write TDD tests for the new commands or queries introduced by the presenter. API clients are mocked — they are abstracted in the domain layer and implemented in the infrastructure layer.
  • Finally, to complete the frontend, write a contract test to implement the API client (the class responsible for making the actual HTTP request to the backend).
it('should load subjects on start', async () => {
    await presenter.start();
 
    verify(actionBus.query(new GetClaimSubjects())).called();
});
 
it('should show all subjects', async () => {
    const subject1 = newClaimSubject();
    const subject2 = newClaimSubject();
    when(actionBus.query(new GetClaimSubjects())).thenResolve([subject1, subject2]);
 
    await presenter.start();
 
    expect(view.model.subjects.length).toEqual(2);
    expect(view.model.subjects[0].id).toEqual(subject1.id);
    expect(view.model.subjects[1].id).toEqual(subject2.id);
});
 
it('should show loading while retrieving subjects', async () => {
    await presenter.start();
 
    expect(view.hasLoaded).toBeTrue();
    expect(view.model.isLoading).toBeFalse();
});
 
it('submit executes createClaim command', async () => {
    presenter.set({ subjectId: fixClaim.subjectId, description: fixClaim.description });
 
    await presenter.submit();
 
    verify(actionBus.exec(new CreateClaim(fixClaim.subjectId, fixClaim.description))).called();
});
 
it('show validation errors that occur on submit', async () => {
    when(actionBus.exec(anything())).thenReject(someValidationErrors);
 
    await presenter.submit();
 
    expect(view.model.errors).toEqual(someValidationErrors.allErrorMessages());
});
 
it('hide loading if submit fails', async () => {
    when(actionBus.exec(anything())).thenReject(someValidationErrors);
 
    await presenter.submit();
 
    expect(view.model.isLoading).toEqual(false);
});
 
it('show success message on success', async () => {
    await presenter.submit();
 
    expect(view.successMessageCalled).toBeTrue();
});

After completing the frontend, we move on to implementing the backend (our teams are cross-functional and full-stack):

  • First, we write an end-to-end test that invokes the use case from the HTTP layer. These tests are similar to ATDD acceptance tests. This test will fail initially, guiding us through the implementation process until the full functionality is in place. We use RestAssured to easily execute and verify HTTP requests. To prepare the scenario, we trigger real commands from the application core. As the test runs and fails, it naturally points to the next piece to implement.
  • We then apply TDD with sociable tests to the use cases (commands, queries, or event handlers). If they depend on infrastructure, we use test doubles (e.g., an in-memory repository implementation). The domain is generally tested indirectly through these use cases — the main domain concepts tend to emerge progressively through refactoring.
  • For more complex scenarios, we sometimes write direct TDD on domain objects themselves, to discover and verify behaviors that are hard to observe from the outside.
  • Finally, we write integration tests for infrastructure components. For instance, we test a real jOOQ repository using a real database. We usually run these tests inside transactions to roll back any changes after execution.

Example end-to-end test:

class CloseClaimApiTest: BaseE2ETest() {
    @Test
    fun `admin can close claim`() {
        val response = request().asAdmin().post("/claims/$claimId/close").exec()
 
        response.assertThat().succeeds(200)
        assertThat(getClaim(claimId).isClosed).isTrue
    }
 
    @Test
    fun `send push notification when claim is closed`() {
        request().asAdmin().post("/claims/$claimId/close").exec()
 
        assertThat(pushNotificationSender.sentNotifications.first().message).startsWith("Su reclamo ha sido cerrado por el administrador")
    }
 
    private fun getClaim(claimId: Int) = repositories.claims().get(Id(claimId))
 
    private val user by lazy { scenario.user { deviceToken("123")} }
    private val claimId by lazy { scenario.claim { opened(); user(user) }.id.toInt() }
}

Example use case test:

class CloseClaimTest {
    @Test
    fun `should close claim with given id`() {
        val claim = scenario.claim { opened() }
 
        handler.execute(CloseClaim(claim.id), admin)
 
        val closedClaim = repositories.claims().get(claim.id)
        assertThat(closedClaim.isClosed).isTrue
    }
 
    @Test
    fun `should trigger ClaimClosed event`() {
        val claim = scenario.claim { opened() }
 
        handler.execute(CloseClaim(claim.id), admin)
 
        assert(eventBus.publishedEvents.any { it is ClaimClosed && it.claim.id == claim.id })
    }
 
    @Test
    fun `restricted to admin`() {
        assertThat(authorizedRoles<CloseClaim>()).contains(Roles.Admin)
    }
 
    private val eventBus = FakeEventBus()
    private val repositories = InMemoryRepositoryProvider(eventBus)
    private val scenario = ScenarioBuilder(repositories)
    private val handler = CloseClaim.Handler(repositories)
    private val admin = AdminIdentity()
}

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

object AddressExamples {
    val streets = TestExamples("Rivadavia 1234", "Campos Salles 2002", "Cabildo 777")
    val cities = TestExamples("Buenos Aires", "CABA", "Rosario", "Córdoba")
    val countryCodes = TestExamples(listOf("AR", "BR", "MX", "UY").map { CountryCode(it) })
    val zipCodes = TestExamples("6500", "1406", "1407", "6300")
    val addresses = TestExamples(List(10) { Address(streets.one(), cities.one(), countryCodes.one(), zipCodes.one()) })
}

Scenario examples:

val user = scenario.user()
val unit = scenario.unit { tenants(user) }
 
// Complex scenario
val manager = scenario.manager {
  code(1234)
  name("Administración Varela")
 
  association {
    code(5678)
    name("Rivadavia 1900")
    contactInfo { address { street("Campos Rivadavia 1900") } }
    paymentProviders(
        CVUTransferProvider(),
        RedepagosProvider(),
        BankTransferProvider(fullnames.one(), taxIds.one(), transferNumbers.one(), transferAliases.one()),
        CashProvider()
    )
  }
}

This last example demonstrates how a condominium manager and its corresponding association can be created declaratively, specifying payment providers, contact information, and other related data. The entire flow is execution-context agnostic: when run in a unit test, the objects are created in in-memory repositories; when run in an integration or end-to-end test, the same objects are persisted to the real database, in the correct order and with proper handling of foreign keys.

To manage time in tests, we modeled a Clock abstraction. In tests, we use a StoppedClock that allows us to control time explicitly. In production code, we use a SystemClock.

Work methodology

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

We work daily using pair and mob programming. This ensures knowledge flows continuously and helps everyone quickly understand all aspects of the system.

We practice TDD and continuous integration. We don't use feature branches in Git — we make micro-commits as part of the TDD cycle, and those are integrated directly into GitLab, where a pipeline ensures everything is in order.

If something fails on our only branch (main), the whole team prioritizes fixing it. Main is always deployable. Every commit that passes the pipeline is automatically deployed to the staging environment, and stakeholders are notified so they can review the latest changes and give daily feedback.

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

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

Because we do continuous integration, micro-commits, and TDD, refactoring is part of our daily routine — it happens naturally in each TDD cycle. This helps keep technical debt under control and allows the codebase to evolve as we learn more about the business.

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

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

We don't estimate. We slice user stories so they take roughly 1 or 2 days to complete. To forecast, we track weekly throughput (i.e., how many stories reach "done" each week) and project forward based on that.

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

For backlog management, we use user story mapping.