SPACY project
Spacy Prototype (Mobile + Backend)
Work

Spacy Prototype (Mobile + Backend)

Apr 2022
Table of Contents

Overview

Spacy Explore is a mobile application designed to help users discover, track, and share interesting “spaces” (places) around them. Unlike traditional map apps, Spacy focuses on the user’s relationship with the space, distinguishing between places they have “Favorited” and places they have “Experienced”.

The application leverages Google Maps for exploration and a Graph Database (Neo4j) backend to efficiently model the complex relationships between users and spaces.

Tech Stack

TechnologyCategoryDescription
FlutterMobileCross-platform development for iOS and Android.
KtorBackendLightweight framework for Kotlin.
KotlinBackendModern, concise programming language used for backend logic.
Neo4jDatabaseGraph database for modeling User-Space relationships.
PostgreSQLDatabaseRelational database for storing normal application data.
gRPCAPI ContractProtocol Buffers used to define the strict API contract between client and server.
RiverpodState ManagementRobust and testable state management solution.
Google Maps SDKMapsInteractive map integration for space discovery.
Cloud RunInfrastructureServerless platform for deploying the backend Docker container.

Backend (Ktor + gRPC + Neo4j + PostgreSQL)

We built a robust backend using Ktor (Kotlin) that serves as the central hub for data. It aggregates structured data from PostgreSQL and relationship data from Neo4j, exposing a unified API via gRPC.

1. Directory Structure

backend/src/main/kotlin/com/spacy/api/
├── Application.kt       # Ktor Application Entry Point
├── GRpcEngine.kt        # gRPC Server Setup
├── SpaceServiceImpl.kt  # gRPC Service Implementation
├── SpaceRepository.kt   # Dual-Database Data Access
└── DatabaseFactory.kt   # DB Connection Management

2. gRPC Server Setup

We use a dedicated GRpcEngine to manage the gRPC server lifecycle, running in parallel with Ktor’s Netty engine.

object GRpcEngine {
    fun start(port: Int) {
        val server = ServerBuilder.forPort(port)
            .addService(SpaceServiceImpl())
            .build()
            .start()

        println("gRPC Server started, listening on $port")
    }
}backend/src/main/kotlin/com/.../GRpcEngine.kt

3. Graph-Based Recommendation Engine

While basic user-space relationships (like “Favorited” or “Experienced”) are stored in PostgreSQL for efficient retrieval, we leverage Neo4j for what it does best: Recommendations.

We implemented two types of graph-based recommendations:

  1. Collaborative Filtering: “People who liked/experienced spaces you liked/experienced also…”
  2. Item-Based Recommendations: “People who liked/experienced this space also…“
class SpaceRepository {
    // 1. User State: Read from Postgres (Fast, Consistent)
    fun getAll(userId: String): List<Space> {
        return transaction {
            val favorites = Favorite.find { Favorite.userId eq userId }.map { it.spaceId }.toSet()
            val experiences = Experience.find { Experience.userId eq userId }.map { it.spaceId }.toSet()

            Spaces.selectAll().map { row ->
                // Construct Space with flags from Postgres sets
            }
        }
    }

    // 2. Discovery: Read from Neo4j (Graph Algorithms)
    fun getRecommendedSpaces(userId: String): List<Space> {
        // "People who liked/experienced what you liked/experienced..."
        val query = """
            MATCH (u:User {id: ${'$'}userId})-[:EXPERIENCED|FAVORITE]->(:Space)<-[:EXPERIENCED|FAVORITE]-(other:User)-[:EXPERIENCED|FAVORITE]->(rec:Space)
            WHERE NOT (u)-[:EXPERIENCED|FAVORITE]->(rec)
            RETURN rec.id, count(*) as score ORDER BY score DESC
        """
        val ids = executeCypher(query, mapOf("userId" to userId))
        return getSpacesByIds(ids, userId) // Fetches details from Postgres & merges flags
    }

    // 3. Related Spaces: "Similar to this space" (Weighted by User Similarity)
    fun getRelatedSpaces(spaceId: String, userId: String): List<Space> {
        val query = """
            MATCH (me:User {id: ${'$'}userId})
            MATCH (p:Space {id: ${'$'}spaceId})
            MATCH (p)<-[:EXPERIENCED|FAVORITE]-(other:User)-[:EXPERIENCED|FAVORITE]->(rec:Space)
            WHERE NOT (me)-[:EXPERIENCED|FAVORITE]->(rec)

            OPTIONAL MATCH (me)-[:EXPERIENCED|FAVORITE]->(shared:Space)<-[:EXPERIENCED|FAVORITE]-(other)
            WITH rec, other, count(shared) as similarity

            RETURN rec.id as spaceId, sum(similarity + 1) as score
            ORDER BY score DESC
            LIMIT 5
        """.trimIndent()

        val ids = executeCypher(query, mapOf("spaceId" to spaceId, "userId" to userId))
        return getSpacesByIds(ids, userId)
    }
}backend/src/main/kotlin/.../SpaceRepository.kt

4. Graph-Based Data Modeling (Neo4j)

The core value of Spacy is discovery. By modeling users and spaces as nodes in a graph, we can traverse relationships to find hidden gems that similar users have enjoyed.

This graph structure allows us to execute complex recommendation queries in milliseconds, which would be computationally expensive in a traditional relational database.

5. API Contract with gRPC & Protobuf

To ensure type safety and a clear contract between the Flutter mobile app and the Ktor backend, we defined the API using Protocol Buffers (protobuf).

service SpaceService {
  rpc GetAll (GetAllRequest) returns (GetAllResponse);
  rpc GetRecommended (GetRecommendedRequest) returns (GetRecommendedResponse);
  rpc GetRelated (GetRelatedRequest) returns (GetRelatedResponse);
}

message Space {
  string id = 1;
  string name = 2;
  double lat = 3;
  double lng = 4;
  string address = 5;
  string category = 6;
  string image_url = 7;

  // User-specific flags
  bool is_favorited = 9;
  bool is_experienced = 10;
}

message GetAllResponse {
  repeated Space spaces = 1;
}proto/space.proto

6. Proto File Management Strategy

We treat our Protobuf definitions as a versioned dependency, stored in a separate repository. This approach has several key advantages over co-locating proto files within service repositories:

  1. Avoids Circular Dependencies: If Service A and Service B both act as clients and servers to each other, maintaining their own proto files can lead to a circular upgrade loop. A separate repo breaks this cycle.
  2. Single Source of Truth: The proto repo acts as the definitive contract for all services.
  3. Language-Agnostic Generation: We can generate language-specific packages (e.g., a Dart package for Mobile, a Kotlin library for Backend) from this central repo, ensuring all clients are always in sync with the latest API definition.

This strategy is particularly crucial for gRPC, where maintaining backward compatibility and strict schema versioning is easier than with more relaxed REST/OpenAPI approaches.

While the mobile application currently consumes these endpoints via JSON/REST for flexibility with Flutter’s ecosystem, the Proto definitions serve as the single source of truth for the API structure, ensuring that both backend and frontend teams are aligned.

Flutter Mobile App

Directory Structure

lib/
├── view/
│   ├── screens/     # Screens (Pages) like Explore, Detail
│   └── widgets/     # Reusable Widgets
├── view_models/     # State Management (Riverpod Notifiers)
├── data/
│   ├── repositories/ # Repository Interfaces & Implementations
│   ├── models/       # Data Models
│   ├── remote/       # API Clients (Dio)
│   └── local/        # Local Storage
└── config/          # App Configuration

Architecture: MVVM + Repository Pattern

We adopted the MVVM (Model-View-ViewModel) pattern combined with the Repository Pattern to ensure a clean separation of concerns and testability, similar to our approach in the Apollo Rehabilitation App.

Mobile Architecture

Component Design Pattern

We strictly separate Container Components (logic/state) from Presentation Components (UI/rendering), a pattern we evolved in CrowdLinks and Apollo.

class CCarousel extends HookConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 1. Connect to State
    final mapSpacesState = ref.watch(mapSpacesNotifierProvider);

    return mapSpacesState.when(
      data: (state) => PCarousel( // 2. Pass to Presentation
        carouselItems: state.carouselItems,
        onPageChanged: (index, reason) {
           // 3. Handle Logic
           ref.read(mapSpacesNotifierProvider.notifier).moveMapTo(index, reason);
        },
      ),
      // ...
    );
  }
}lib/view/widgets/explore/c_carousel.dart

State Management with Riverpod

We use Riverpod to manage the state of the map and the spaces being displayed. The MapSpacesNotifier handles fetching spaces based on the user’s location and filters.

final mapSpacesProvider = StateNotifierProvider.autoDispose<MapSpacesNotifier, AsyncValue<List<Space>>>((ref) {
  return MapSpacesNotifier(ref.read(spaceRepositoryProvider));
});

class MapSpacesNotifier extends StateNotifier<AsyncValue<List<Space>>> {
  MapSpacesNotifier(this._repository) : super(const AsyncValue.loading()) {
    fetchSpaces();
  }

  final SpaceRepository _repository;

  Future<void> fetchSpaces() async {
    try {
      state = const AsyncValue.loading();
      final spaces = await _repository.findByUserId("current_user_id");
      state = AsyncValue.data(spaces);
    } catch (e, st) {
      state = AsyncValue.error(e, st);
    }
  }
}lib/view_models/map_spaces_notifier.dart

Repository Pattern Implementation

The repository pattern abstracts the API calls. The implementation uses a gRPC Client to communicate with the Ktor backend.

class SpaceRepository {
  late final SpaceServiceClient _client;

  SpaceRepository(this._ref) {
    const host = String.fromEnvironment('GRPC_HOST', defaultValue: 'localhost');
    const port = int.fromEnvironment('GRPC_PORT', defaultValue: 50051);

    final channel = ClientChannel(
      host,
      port: port,
      options: const ChannelOptions(credentials: ChannelCredentials.insecure()),
    );
    _client = SpaceServiceClient(channel);
  }

  Future<List<Space>> getSpaces() async {
    final request = GetAllRequest(userId: 'user-123'); // Mock User ID
    final response = await _client.getAll(request);

    return response.spaces.map((s) => Space(
      id: s.id,
      name: s.name,
      lat: s.lat,
      lng: s.lng,
      address: s.address,
      category: s.category,
      imageUrl: s.imageUrl,

      isFavorited: s.isFavorited,
      isExperienced: s.isExperienced,
    )).toList();
  }
}lib/data/repositories/space_repository.dart

Google Maps Integration

The ExploreScreen integrates GoogleMap to visualize the spaces. Markers are dynamically generated based on the list of spaces in the MapSpacesNotifier state.

class ExploreScreen extends HookConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final spacesState = ref.watch(mapSpacesProvider);

    return Scaffold(
      body: spacesState.when(
        data: (spaces) => GoogleMap(
          initialCameraPosition: _kTokyoStation,
          markers: spaces.map((space) => Marker(
            markerId: MarkerId(space.id),
            position: LatLng(space.lat, space.lng),
            infoWindow: InfoWindow(title: space.name),
          )).toSet(),
        ),
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (e, _) => CommonErrorWidget(
          error: e,
          onRetry: () => ref.refresh(mapSpacesProvider),
        ),
      ),
    );
  }
}lib/view/screens/explore_screen.dart

Related Projects

    Mike 3.0

    Send a message to start the chat!

    You can ask the bot anything about me and it will help to find the relevant information!

    Try asking: