Как Junie от JetBrains помогла ускорить разработку внутреннего проекта

Всем привет! Я Станислав Радченко, Android-разработчик из Effective. В этой статье я расскажу о том, как с помощью Junie от JetBrains мы ускорили разработку внутреннего проекта — приложения для бронирования переговорных комнат.

Как Junie от JetBrains помогла ускорить разработку внутреннего проекта

Junie — AI-агент от JetBrains, который помогает автоматизировать целый ряд задач в IDE.

Он может самостоятeльно читать структуру проекта, работать с файлами, запускать код, тесты и терминальные команды, вносить изменения в файловую систему. При этом контроль остаётся за человеком: пользователь задаёт задачу, даёт одобрение или корректировку промежуточных шагов — а Junie показывает план действий и отчёты о выполнении.

Как Junie от JetBrains помогла ускорить разработку внутреннего проекта

Структура статьи

  1. О проекте
  2. Стек проекта
  3. Почему мы выбрали Junie
  4. Как Junie применялся в проекте
  5. Примеры работы Junie
  6. Плюсы и минусы
  7. Выводы

О проекте

Effective Office — внутренняя экосистема, которая объединяет приложения для автоматизации офисных процессов. Одно из них отвечает за бронирование переговорных комнат.

Несмотря на то что IT-компании уже давно перешли на гибридный или полностью удалённый формат, работа в физическом офисе по‑прежнему не теряет своей актуальности: встречи с командой, брейнштормы, важные звонки — всё это требует переговорных комнат и чёткой системы их бронирования. Когда таких ресурсов становится много, ручное управление превращается в хаос: пересекающиеся брони, потерянные слоты и вечное «А кто занял эту комнату?».

Мы решили автоматизировать этот процесс и создать внутренний сервис, который управляет всеми ресурсами офиса. Серверная часть реализована на Spring Boot и предоставляет REST API для работы с бронированием, а клиентское приложение на Kotlin Multiplatform развёрнуто на планшетах у дверей переговорных. На них сотрудники в реальном времени видят статус конкретной переговорной и могут мгновенно забронировать слот прямо с устройства — без переключения на ноутбук или телефон.

Чтобы исключить ручные действия и максимально интегрироваться в экосистему компании, мы добавили поддержку Google Workspace и Google Calendar, что позволило синхронизировать все встречи автоматически.

Проект сразу задумывался как Open Source, потому что мы хотели не только решить задачу внутри своей компании, но и поделиться опытом с другими разработчиками.

Код доступен для изучения, доработки и контрибьюта, а сама архитектура построена так, чтобы её можно было легко адаптировать под разные сценарии.

Однако в этой статье я хочу рассказать не только о технической стороне проекта, но и о том, как мы использовали AI-агента Junie для его реализации.

Junie не просто инструмент для генерации кода — он стал полноценным участником процесса разработки: помогал проектировать архитектуру, писать boilerplate, документировать API и даже искал узкие места! Я покажу, где Junie действительно ускоряет работу, а где всё ещё нужен опыт разработчика.

Если вы работаете с Kotlin интересуетесь AI‑инструментами или просто хотите увидеть, как можно автоматизировать офисные процессы, наш опыт будет вам полезен.

Стек проекта

Как Junie от JetBrains помогла ускорить разработку внутреннего проекта

Effective Office существует уже 3 года, и изначально разрабатывался студентами как учебная задача, поэтому у него было много проблем с кодом и инфраструктурой. Неудачные технические решения, устаревшие библиотеки и сложность поддержки привели нас к решению отрефакторить проект с нуля.

При рефакторинге хотелось найти баланс между простотой и надёжностью. Важно было, чтобы новые разработчики легко ориентировались в структуре проекта и могли быстро поднимать его локально, а деплой не превращался в отдельный квест.

В итоге получилась следующая структура:

  • Многомодульная архитектура: gradle-проект с модулями backend, client. Так как бэкенд и клиент — это модули одного проекта, мы при желании сможем переиспользовать общие модели данных и сформировать более строгий контракт между слоями. Плюс ко всему, система реализована как монорепозиторий. В будущем мы планируем добавлять новые сервисы, поэтому иметь для них единую точку входа будет удобно;
  • Модуль build-logic содержит Gradle Conventions Plugins для упрощения работы с множеством модулей и снижения дублирования конфигураций.

Backend

Серверная часть реализована на Kotlin и Spring Boot. Она отвечает за:

  • REST API для работы с бронированиями (CRUD для переговорных, создание и отмена бронирований);
  • Интеграцию с Google Calendar для автоматической синхронизации событий;
  • Авторизацию через Google Workspace;
  • Хранение данных в PostgreSQL.

Client

Приложение создано на Kotlin Multiplatform с UI на Compose Multiplatform.

Интерфейс минималистичен: крупные кнопки, высокая контрастность, минимум шагов для бронирования.

Инфраструктура

  • Сборка Docker-образа и деплой на выделенный сервер. Для автоматизации этого процесса мы используем Gradle Tasks.
  • Разделение окружения на dev и prod для безопасного тестирования и стабильной работы продакшена.
  • Возможность запуска сервиса локально в несколько команд без сложной конфигурации.

Такой подход позволяет запускать полный цикл разработки и тестирования прямо на локальной машине, а деплой на сервер занимает считанные минуты. Всё это делает проект дружелюбным как для опытных разработчиков, так и для новичков в команде.

Почему мы выбрали Junie

Мы хотели провести рефакторинг за минимальные сроки, и в этом нам должны были помочь AI-агенты.

Очень удачно к тому моменту на KotlinConf 2025 показали Junie — AI-агента, встроенного прямо в IntelliJ IDEA.

Идея мгновенно откликнулась: привычная среда разработки для Kotlin, никаких переключений между браузером и IDE, никакого бесконечного копипаста из ChatGPT. Забегая вперёд, скажу: решение использовать Junie — оказалось именно тем, что нам было нужно.

Как Junie применялся в проекте

Инициализация backend проекта

Первостепенной задачей было создание базового проекта и структуры модулей для backend-приложения. На мой взгляд, Junie отлично справился с этой задачей. Используя этот промпт:

Generate Spring Boot Kotlin project. Connect all necessary libraries for developing backend. I want that project was multi-module gradle project. Use gradle.kts. Use version catalog for storing dependencies. Use gradle convention plugins. Generate some documentation. Follow Spring guidelines and standard common practices. Also create base docker setup. Database setup on postgres will be usefull. Generate base CRUD for example.

Junie составил план:

1. Create a basic project structure for a multi-module Spring Boot Kotlin project - Create root build.gradle.kts - Create settings.gradle.kts - Create gradle.properties - Create gradle/libs.versions.toml for version catalog 2. Set up convention plugins for Gradle - Create buildSrc directory with convention plugins - Create Kotlin convention plugin - Create Spring Boot convention plugin - Create Spring Boot application convention plugin - Create JPA convention plugin 3. Set up the domain/entity module 4. Set up the repository/database module 5. Set up the main application module 6. Create a basic CRUD example 7. Add Docker configuration - Create Dockerfile - Create docker-compose.yml 8. Update README.md with documentation 9. Create Gradle wrapper - Create gradlew - Create gradlew.bat - Create gradle/wrapper/gradle-wrapper.properties - Note: gradle-wrapper.jar would need to be generated by running `gradle wrapper` in a real project 10. Summarize the project and provide final notes

На его основе Junie создал структуру проекта.

Что можно отметить на этом этапе?

  • Создан backend app модуль
  • Часть библиотек вынесена в libs.version.toml
  • Созданы и применены модули convention plugins
  • Создан базовый CRUD для пользователей: модели Dto, Domain и Entity, Contoller, Service и Application-класс
  • Реализована интеграция с базой данных и файл миграции
  • Настроен Docker-файл
  • Сгенерирован REAMDE с гайдом к запуску различных environments

Потом я попросил Junie обновить все библиотеки до самых последних версий:

Update versions of all libraries for newest

Результат:

Как Junie от JetBrains помогла ускорить разработку внутреннего проекта

Как видите, библиотеки обновлены, но не всегда до самой последней версии. Например, версия Kotlin.

Вообще, я заметил, что, когда работа доходит до какой-то конкретной версии библиотеки, Junie это игнорирует и ориентируется на ту версию, которую знает. Отдельно об этом расскажу в главе о миграции клиентских фичей.

Отдельным промптом я попросил сделать несколько окружений:

create different runtime profiles: local This profile aims to configure an environment that can be developed even if the network is disconnected. local-dev This profile aims configurations that allow me to connect to the DEV environment from my local machine. dev This profile exists for deploying Development environments. staging This profile exists for deploying Staging environments. live This profile exists for deploying Live environments.

Результат:

Как Junie от JetBrains помогла ускорить разработку внутреннего проекта

Как и следовало ожидать, проект не запустился с первого раза. Однако, пересылая ошибки из консоли в Junie, парой-тройкой промптов мы довели проект до успешной сборки.

В целом я считаю результат отличным. Да, Junie не всегда работает идеально и требует контроля, но даже в таком виде он ускорил разработку в разы.

Я считаю, что мне как Android-разработчику без опыта в backend Junie сильно помог на этом этапе. Одним подробным промптом Junie сгенерировал базовый CRUD проект с гайдом о том, как его запускать.

Миграция backend-фичей из legacy проекта

Следующей задачей была миграция кода старого Ktor-бэкенда на Kotlin Spring Boot.

Для успешной миграции мне было важно понять, как работал legacy-код, чтобы иметь полный контекст проекта. Поэтому перед переносом каждой фичи я:

  • просил у Junie объяснения конкретных участков старого проекта;
  • запрашивал у него список критических проблем и потенциальных рисков;
  • после получения контекста передавал его Junie.

Структура модулей

Для начала я задал Junie структуру модулей проекта:

Please remember the curren module structure and follow it for any new feature implementation or migration. Rules: - Each feature module must be self-contained. - Follow clean architecture principles: `controller -> service -> domain -> persistence`. - All secrets, keys, and environment-specific variables must be placed in `.env`. - Ensure Gradle Convention Plugins are used to configure each module. - Code style must match the existing project conventions.

Промпт для миграции фичи бронирования переговорных:

migrate implementation of the booking system in oldBackendExample:features:booking to my project backend:feature:booking. according to the following architectural principles: 1. Separate core business logic from external dependencies by introducing an interface (e.g., CalendarProvider) in the core module, defining operations like createEvent, updateEvent, and deleteEvent. 2. Implement different calendar providers (e.g., Google Calendar, Dummy Calendar) as adapters in separate feature modules that implement this interface. 3. Update the BookingService so it uses the CalendarProvider interface instead of directly integrating with Google Calendar. This ensures the service is decoupled from any specific calendar implementation. 4. Inject the desired CalendarProvider implementation into BookingService using Spring’s dependency injection mechanism. Configure the implementation choice via Spring configuration or application properties. 5. Store the external event ID (returned by the calendar provider) in the booking record, so that we can manage updates or deletions in the future. Please apply this approach to refactor the existing code, ensuring that: • The core module contains only the business logic and the interface definition. • Each feature module contains the specific implementation of a calendar provider. • BookingService in the core module remains agnostic of the specific provider implementation.

Результат:

/** * Service for managing bookings. * This service uses a CalendarProvider to interact with the calendar. */ @Service class BookingService( private val calendarProvider: CalendarProvider, ) { /** * Creates a new booking. * * @param booking The booking to create * @return The created booking with the external event ID set */ fun createBooking(booking: Booking): Booking { return calendarProvider.createEvent(booking) } /** * Updates an existing booking. * * @param booking The booking with updated information * @return The updated booking */ fun updateBooking(booking: Booking): Booking { return calendarProvider.updateEvent(booking) } /** * Deletes a booking. * * @param booking The booking to delete */ fun deleteBooking(booking: Booking) { calendarProvider.deleteEvent(booking) } /** * Deletes a booking by its ID. * * @param id The ID of the booking to delete * @return true if the booking was deleted, false if it wasn't found */ fun deleteBookingById(id: UUID): Boolean { val booking = getBookingById(id) ?: return false calendarProvider.deleteEvent(booking) return true } /** * Gets a booking by its ID. * * @param id The ID of the booking * @return The booking if found, null otherwise */ fun getBookingById(id: UUID): Booking? { return calendarProvider.findEventById(id) } /** * Gets all bookings for a workspace within a time range. * * @param workspaceId The ID of the workspace * @param from The start of the time range * @param to The end of the time range (optional) * @return A list of bookings for the workspace within the time range */ fun getBookingsByWorkspace(workspaceId: UUID, from: Instant, to: Instant? = null): List<Booking> { return calendarProvider.findEventsByWorkspace(workspaceId, from, to) } /** * Gets all bookings for a user within a time range. * * @param userId The ID of the user * @param from The start of the time range * @param to The end of the time range (optional) * @return A list of bookings for the user within the time range */ fun getBookingsByUser(userId: UUID, from: Instant, to: Instant? = null): List<Booking> { return calendarProvider.findEventsByUser(userId, from, to) } /** * Gets all bookings for a user and workspace within a time range. * * @param userId The ID of the user * @param workspaceId The ID of the workspace * @param from The start of the time range * @param to The end of the time range (optional) * @return A list of bookings for the user and workspace within the time range */ fun getBookingsByUserAndWorkspace( userId: UUID, workspaceId: UUID, from: Instant, to: Instant? = null ): List<Booking> { return calendarProvider.findEventsByUser(userId, from, to) .filter { it.workspace.id == workspaceId } } }
/** * Google Calendar implementation of the CalendarProvider interface. */ @Component class GoogleCalendarProvider( private val calendar: Calendar, private val calendarIdProvider: CalendarIdProvider ) : CalendarProvider { private val logger = LoggerFactory.getLogger(GoogleCalendarProvider::class.java) private val defaultCalendar = "primary" // Default calendar ID override fun createEvent(booking: Booking): Booking { logger.debug("Creating event for booking: {}", booking) val workspaceCalendarId = getCalendarIdByWorkspace(booking.workspace.id) val event = convertToGoogleEvent(booking) val savedEvent = calendar.events().insert(defaultCalendar, event).execute() // Check if the event was successfully created in the workspace calendar if (!checkEventAvailability(savedEvent, workspaceCalendarId)) { // If not available, delete the event and throw an exception deleteEventById(savedEvent.id) throw WorkspaceUnavailableException("Workspace ${booking.workspace.name} is unavailable at the requested time") } // Return the booking with the external event ID set return booking.copy(externalEventId = savedEvent.id) } override fun updateEvent(booking: Booking): Booking { logger.debug("Updating event for booking: {}", booking) val externalEventId = booking.externalEventId ?: throw IllegalArgumentException("Booking must have an external event ID to be updated") val workspaceCalendarId = getCalendarIdByWorkspace(booking.workspace.id) val event = convertToGoogleEvent(booking) val updatedEvent = calendar.events().update(defaultCalendar, externalEventId, event).execute() // Check if the updated event is available in the workspace calendar if (!checkEventAvailability(updatedEvent, workspaceCalendarId)) { // If not available, revert to the previous version and throw an exception val previousEvent = calendar.events().get(defaultCalendar, externalEventId).execute() calendar.events().update(defaultCalendar, externalEventId, previousEvent).execute() throw WorkspaceUnavailableException("Workspace ${booking.workspace.name} is unavailable at the requested time") } return booking } override fun deleteEvent(booking: Booking) { logger.debug("Deleting event for booking: {}", booking) val externalEventId = booking.externalEventId ?: throw IllegalArgumentException("Booking must have an external event ID to be deleted") deleteEventById(externalEventId) } private fun deleteEventById(eventId: String) { try { calendar.events().delete(defaultCalendar, eventId).execute() } catch (e: GoogleJsonResponseException) { if (e.statusCode != 404 && e.statusCode != 410) { throw e } // If the event doesn't exist (404) or has been deleted (410), ignore the exception logger.warn("Event with ID {} not found or already deleted", eventId) } } override fun findEventsByWorkspace(workspaceId: UUID, from: Instant, to: Instant?): List<Booking> { logger.debug( "Finding events for workspace with ID {} from {} to {}", workspaceId, from, to ?: "infinity" ) val workspaceCalendarId = getCalendarIdByWorkspace(workspaceId) val events = listEvents(workspaceCalendarId, from, to) return events.map { convertToBooking(it) } } override fun findEventsByUser(userId: UUID, from: Instant, to: Instant?): List<Booking> { logger.debug( "Finding events for user with ID {} from {} to {}", userId, from, to ?: "infinity" ) // In a real implementation, we would need to query the user's email from a user repository // For simplicity, we'll assume we have a method to get the user's email val userEmail = getUserEmailById(userId) // Get all calendar IDs val calendarIds = calendarIdProvider.getAllCalendarIds() // Query all calendars for events with the user as an attendee or organizer val allEvents = mutableListOf<Event>() for (calendarId in calendarIds) { val events = listEvents(calendarId, from, to, userEmail) allEvents.addAll(events.filter { event -> event.organizer?.email == userEmail || event.attendees?.any { it.email == userEmail } == true }) } return allEvents.map { convertToBooking(it) } } override fun findEventById(id: UUID): Booking? { logger.debug("Finding event with ID {}", id) // In a real implementation, we would need to map the booking ID to the external event ID // For simplicity, we'll assume the booking ID is the external event ID val externalEventId = id.toString() return try { val event = calendar.events().get(defaultCalendar, externalEventId).execute() convertToBooking(event) } catch (e: GoogleJsonResponseException) { if (e.statusCode == 404) { null } else { throw e } } } // Helper methods private fun getCalendarIdByWorkspace(workspaceId: UUID): String { return try { calendarIdProvider.getCalendarIdByWorkspace(workspaceId) } catch (e: Exception) { logger.warn("Failed to get calendar ID for workspace {}, using default calendar", workspaceId) defaultCalendar } } private fun listEvents(calendarId: String, from: Instant, to: Instant?, q: String? = null): List<Event> { val eventsRequest = calendar.events().list(calendarId) .setTimeMin(DateTime(from.toEpochMilli())) .setSingleEvents(true) if (to != null) { eventsRequest.timeMax = DateTime(to.toEpochMilli()) } if (q != null) { eventsRequest.q = q } return eventsRequest.execute().items ?: emptyList() } private fun convertToGoogleEvent(booking: Booking): Event { val event = Event() .setSummary("Booking: ${booking.workspace.name}") .setDescription("Booking created by ${booking.owner.firstName} ${booking.owner.lastName}") .setStart(EventDateTime().setDateTime(DateTime(booking.beginBooking.toEpochMilli()))) .setEnd(EventDateTime().setDateTime(DateTime(booking.endBooking.toEpochMilli()))) // Add attendees val attendees = booking.participants.map { user -> EventAttendee().setEmail(user.email) } event.attendees = attendees // Add the owner as the organizer event.organizer = EventAttendee().setEmail(booking.owner.email) return event } private fun convertToBooking(event: Event): Booking { // In a real implementation, we would need to map the Google Calendar event to a Booking // This would involve looking up users and workspaces by their IDs or emails // For simplicity, we'll create dummy objects val owner = createDummyUser(event.organizer?.email ?: "unknown@example.com") val participants = event.attendees?.map { attendee -> createDummyUser(attendee.email) } ?: emptyList() val workspace = createDummyWorkspace(event.summary ?: "Unknown Workspace") return Booking( id = UUID.randomUUID(), // In a real implementation, we would map this to a persistent ID owner = owner, participants = participants, workspace = workspace, beginBooking = Instant.ofEpochMilli(event.start.dateTime.value), endBooking = Instant.ofEpochMilli(event.end.dateTime.value), externalEventId = event.id ) } private fun createDummyUser(email: String): User { return User( id = UUID.randomUUID(), username = email.substringBefore("@"), email = email, firstName = "Dummy", lastName = "User" ) } private fun createDummyWorkspace(name: String): Workspace { return Workspace( id = UUID.randomUUID(), name = name, tag = "meeting" ) } private fun getUserEmailById(userId: UUID): String { // In a real implementation, we would look up the user's email in a repository // For simplicity, we'll return a dummy email return "user-$userId@example.com" } private fun checkEventAvailability(event: Event, workspaceCalendarId: String): Boolean { // In a real implementation, we would check if the workspace is available at the requested time // For simplicity, we'll assume it's always available return true } // Exception class for workspace unavailability class WorkspaceUnavailableException(message: String) : RuntimeException(message) } /** * Interface for providing calendar IDs for workspaces. */ interface CalendarIdProvider { fun getCalendarIdByWorkspace(workspaceId: UUID): String fun getAllCalendarIds(): List<String> }

Результат работы Junie показал, что такой подход работает:

  • соблюдены общий code style и структура модулей;
  • перенесено много компонентов и логики, однако часто Junie оставлял моковую реализацию с комментарием «В production-коде мы бы сделали так-то»;
  • реализована абстракция над фреймворком календаря;
  • для создания модулей использованы gradle-convention плагины;
  • константы, ключи и секреты вынесены в файл конфигурации и подгружаются через .env.

После этого потребовались следующие доработки:

override fun findEventsByUser(userId: UUID, from: Instant, to: Instant?): List<Booking> { ... // In a real implementation, we would need to query the user's email from a user repository // For simplicity, we'll assume we have a method to get the user's email val userEmail = getUserEmailById(userId) // Get all calendar IDs val calendarIds = calendarIdProvider.getAllCalendarIds() ... } private fun getUserEmailById(userId: UUID): String { // In a real implementation, we would look up the user's email in a repository // For simplicity, we'll return a dummy email return "user-$userId@example.com" } private fun checkEventAvailability(event: Event, workspaceCalendarId: String): Boolean { // In a real implementation, we would check if the workspace is available at the requested time // For simplicity, we'll assume it's always available return true }

Миграция фичей client из legacy-проекта

После миграции бэкенда мы вернулись к клиентской части. У нас уже было старое приложение, но его код требовал серьёзного рефакторинга: хотелось упростить архитектуру и избавиться от лишнего оверхеда.

Раньше мы использовали связку Decompose + MVI Kotlin, но для нашего уровня проекта это оказалось слишком тяжеловесным решением. Мы решили упростить логику и перейти к модели Decompose + MVI прямо в Component-классах, что дало более прозрачный и понятный код без лишних абстракций.

Чтобы помочь Junie работать в контексте проекта, я положил папку со старым кодом прямо в новый репозиторий. Так агент мог анализировать не только текущие файлы, но и весь legacy-код.

Затем я создал новый Compose Multiplatform модуль для планшетов. Именно с него мы начали рефакторинг клиентской части.

Junie помогал и на этом этапе, но не так уверенно, как на бэкенде. Он генерировал базовую структуру экранов, писал компоненты и предлагал упрощения, но в реальной практике это часто требовало ручных доработок. Особенно это было заметно при работе с Decompose и специфичными для KMP задачами — Junie понимал их хуже, чем стандартный Spring Boot код.

Наивно я попробовал попросить Junie перенести весь старый код приложения разом. Для этого я положил в новый репозиторий папку с legacy-проектом и дал агенту следующий промпт:

Analyze oldProject/tabletApp/improvement_plan.md and migrate oldProject/tabletApp with improved architecture to my project clients/tablet. - considering existing convention plugins when adding new modules and dependencies - follow the principles of Clean Architecture, SOLID, KISS, DRY. - keep in mind that the application is based on Compose Multiplatform with Android, iOS targets - keep in mind that the project is multi-module - take into account the errors, bugs, weaknesses of the old implementation and fix them in the new one - divide the code into existing modules: common entities into core modules. There are only feature components in feature modules. - feature modules should contain the following structure: presentation (UI, Decompose, MVI, ), domain (repository interfaces, usecases with interfaces and implementations), data (repository impl) core-data must contain an implementation of a multiplatform http client based on Ktor.

Результат был, мягко говоря, далёким от ожидаемого:

Как Junie от JetBrains помогла ускорить разработку внутреннего проекта

Junie сгенерировал лишь пустой файл .output.txt и шаблонный build.gradle.kts. Полноценного переноса не произошло.

Я решил, что, возможно, для Junie это слишком большой объём работы за один шаг. Поэтому попросил его продолжить:

Как Junie от JetBrains помогла ускорить разработку внутреннего проекта

Уже что-то, но всё ещё далеко от полноценного рефакторинга. Например, при переносе Ktor-клиента Junie сделал следующий вариант (старый код можно посмотреть здесь):

/** * Factory for creating platform-specific HTTP clients */ expect object HttpClientFactory { /** * Creates a platform-specific HTTP client * @return HttpClient instance */ fun createHttpClient(): HttpClient } /** * Android-specific implementation of HttpClientFactory */ actual object HttpClientFactory { /** * Creates an Android-specific HTTP client using OkHttp engine * @return HttpClient instance */ actual fun createHttpClient(): HttpClient = HttpClient(OkHttp) } /** * iOS-specific implementation of HttpClientFactory */ actual object HttpClientFactory { /** * Creates an iOS-specific HTTP client using Darwin engine * @return HttpClient instance */ actual fun createHttpClient(): HttpClient = HttpClient(Darwin) } /** * Utility class for making HTTP requests and handling errors */ object HttpRequestUtil { /** * HTTP request methods */ enum class Method { GET, POST, PUT, DELETE } /** * Result of an HTTP request */ sealed class Result<out T> { /** * Successful result with data */ data class Success<T>(val data: T) : Result<T>() /** * Error result with error code and message */ data class Error(val code: Int, val message: String) : Result<Nothing>() } /** * Makes an HTTP request and returns the result * @param client HttpClient instance * @param url URL to request * @param method HTTP method to use * @param block Request configuration block * @return Result of the request */ suspend inline fun <reified T> request( client: HttpClient = HttpClientProvider.create(), url: String, method: Method = Method.GET, crossinline block: HttpRequestBuilder.() -> Unit = {} ): Result<T> = withContext(Dispatchers.IO) { try { val response: HttpResponse = when (method) { Method.GET -> client.get(url) { block() } Method.POST -> client.post(url) { block() } Method.PUT -> client.put(url) { block() } Method.DELETE -> client.delete(url) { block() } } if (response.status.value in 200..299) { Result.Success(response.body()) } else { Result.Error(response.status.value, "HTTP error: ${response.status.value}") } } catch (e: Exception) { Result.Error(0, e.message ?: "Unknown error") } } }

Глобально изменений немного, но мне понравилось, что Junie вынес обёртку для запросов в отдельный утилитный класс. Тем не менее это всё ещё был не полный перенос legacy-функционала. Я снова попросил его продолжить:

Как Junie от JetBrains помогла ускорить разработку внутреннего проекта

На этот раз Junie перешёл к UI-компонентам. Например, вот как он переписал экран ошибки (предыдущая версия):

/** * Error screen to display when there's a connection issue * * @param resetRequest Callback to be invoked when the reset button is clicked * @param disconnectText Text to display for the disconnect message * @param resetText Text to display for the reset instructions * @param resetButtonText Text to display on the reset button */ @Composable fun ErrorMainScreen( resetRequest: () -> Unit, disconnectText: String = "Connection lost", resetText: String = "Please try to reset the connection", resetButtonText: String = "Reset" ) { Column( modifier = Modifier .fillMaxSize() .background(color = MaterialTheme.colorScheme.background), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { // Note: Replace with actual image resource when available // Image(painter = painterResource(Res.image.disconnect), contentDescription = null) Spacer(modifier = Modifier.height(30.dp)) Text( text = disconnectText, style = MaterialTheme.typography.headlineMedium ) Spacer(modifier = Modifier.height(30.dp)) Text( text = resetText, style = MaterialTheme.typography.headlineSmall, color = LocalCustomColorsPalette.current.secondaryTextAndIcon ) Spacer(modifier = Modifier.height(60.dp)) SuccessButton( modifier = Modifier .height(60.dp) .fillMaxWidth(0.3f), onClick = { resetRequest() } ) { Text( text = resetButtonText, style = MaterialTheme.typography.titleMedium ) } } }

Основная проблема здесь — ресурсы. Junie плохо справляется с ресурсами, а ситуация осложнялась ещё и тем, что в старом проекте мы использовали moko-resources, а при рефакторинге решили перейти на нативные ресурсы Compose Multiplatform.

Ещё один показательный случай произошёл, когда Junie попытался перенести Composable-функцию кнопки с иконкой «Крестик». В изначальной реализации мы использовали ресурс изображения, но Junie по какой-то причине решил его не переносить и вместо этого… нарисовал крестик вручную на Canvas.

/** * A button with a cross icon, typically used for closing dialogs or screens * * @param modifier Modifier to be applied to the button * @param onDismissRequest Callback to be invoked when the button is clicked */ @Composable fun CrossButtonView(modifier: Modifier, onDismissRequest: () -> Unit) { Box( modifier = modifier, contentAlignment = Alignment.CenterEnd ) { IconButton( onClick = { onDismissRequest() }, modifier = Modifier.size(40.dp) ) { // Note: In the original implementation, this used a vector resource // For now, we'll use a simple X character as a placeholder // For now, we'll draw a simple X using Canvas // In a real implementation, you would use a proper icon resource Icon( imageVector = androidx.compose.material.icons.Icons.Default.Close, contentDescription = "Close", modifier = Modifier.size(25.dp), tint = LocalCustomColorsPalette.current.secondaryTextAndIcon ) Canvas(modifier = Modifier.size(25.dp)) { val color = LocalCustomColorsPalette.current.secondaryTextAndIcon val strokeWidth = 2.5f // Draw the X drawLine( color = color, start = Offset(0f, 0f), end = Offset(size.width, size.height), strokeWidth = strokeWidth, cap = StrokeCap.Round ) drawLine( color = color, start = Offset(0f, size.height), end = Offset(size.width, 0f), strokeWidth = strokeWidth, cap = StrokeCap.Round ) } } } }

Таким образом я постепенно переносил проект, подсказывая и уточняя агенту детали реализации на каждом шаге.

В результате удалось добиться того, что одна полноценная фича приложения заработала. Дальше стало проще: контекст у Junie расширялся, появлялось всё больше готовых примеров, и можно было мигрировать новые части проекта по аналогии.

migrate oldProject/tabletApp/features/selectRoom/src/commonMain/kotlin/band/effective/office/tablet/ui/updateEvent to my project module clients/tablet/feature/selectroom. No need store classess to migrate make all MVI code right in component class like in fastevent, freeselectroom packages

Результат:

/** * Component for updating an event */ class UpdateEventComponent( private val event: EventInfo, private val room: String, private val onDelete: (String) -> Unit, private val onCloseRequest: () -> Unit, private val onEventCreation: (EventInfo) -> Unit, private val onEventUpdate: (EventInfo) -> Unit, private val bookingRepository: BookingRepository, private val checkBookingUseCase: CheckBookingUseCase, private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Main) ) : ModalWindow { // UI state private val _state = MutableStateFlow(UpdateEventState()) val state: StateFlow<UpdateEventState> = _state.asStateFlow() // Modal state private val _modalState = MutableStateFlow<ModalState>(ModalState.Update) val modalState: StateFlow<ModalState> = _modalState.asStateFlow() // Date time picker component val dateTimePickerComponent: DateTimePickerComponent by lazy { DateTimePickerComponent( checkBookingUseCase = checkBookingUseCase, onSelectDate = { onSetDate(it) }, onCloseRequest = { onCloseSelectDateDialog() }, initialDate = "2023-01-01T12:00:00Z", // Default date-time event = event, room = room, duration = 30 // Default duration ) } init { // Initialize state with event data _state.update { it.copy( duration = 30, // Default duration selectOrganizer = event.organizer, inputText = event.organizer.fullName, event = event, // Load organizers (hardcoded for simplicity) organizers = listOf( Organizer("John Doe", "1", "john@example.com"), Organizer("Jane Smith", "2", "jane@example.com"), Organizer("Bob Johnson", "3", "bob@example.com") ) ) } } // Intent handlers fun onUpdateLength(update: Int) { val newDuration = (state.value.duration + update).coerceIn(15, 1440) _state.update { it.copy(duration = newDuration) } checkAvailability() } fun onUpdateDate(updateInDays: Int) { // This would normally update the date by adding days // For simplicity, we'll just trigger a check checkAvailability() } fun onSetDate(dateString: String) { // Update state with new date _state.update { it.copy(showSelectDate = false) } checkAvailability() } fun onExpandedChange() { _state.update { it.copy(expanded = !it.expanded) } } fun onSelectOrganizer(newOrganizer: Organizer) { _state.update { it.copy( selectOrganizer = newOrganizer, inputText = newOrganizer.fullName, expanded = false ) } } fun onUpdateEvent() { createOrUpdateEvent() } fun onDeleteEvent() { coroutineScope.launch { _state.update { it.copy(isLoadDelete = true) } try { bookingRepository.deleteBooking(state.value.event.id) .onSuccess { onDelete(state.value.event.id) onCloseRequest() } .onFailure { _state.update { it.copy(isErrorDelete = true) } } } catch (e: Exception) { _state.update { it.copy(isErrorDelete = true) } } finally { _state.update { it.copy(isLoadDelete = false) } } } } fun onInput(input: String) { val filteredOrganizers = state.value.organizers.filter { it.fullName.contains(input, ignoreCase = true) } _state.update { it.copy( inputText = input, selectOrganizers = filteredOrganizers, isInputError = filteredOrganizers.isEmpty() && input.isNotEmpty() ) } checkEnableButton() } fun onDoneInput() { val currentState = state.value val organizer = currentState.organizers.firstOrNull { it.fullName.equals(currentState.inputText, ignoreCase = true) } ?: currentState.selectOrganizer _state.update { it.copy(selectOrganizer = organizer, expanded = false) } } fun onOpenSelectDateDialog() { _state.update { it.copy(showSelectDate = true) } } fun onCloseSelectDateDialog() { _state.update { it.copy(showSelectDate = false) } } fun onClose() { onCloseRequest() } fun onBooking() { createOrUpdateEvent() } // Helper methods private fun createOrUpdateEvent() { coroutineScope.launch { try { val eventInfo = createEventInfo() if (state.value.event.isNotCreated()) { bookingRepository.createBooking(eventInfo, room) .onSuccess { onEventCreation(it) _modalState.value = ModalState.Success } .onFailure { _modalState.value = ModalState.Failure } } else { bookingRepository.updateBooking(eventInfo, room) .onSuccess { onEventUpdate(it) _modalState.value = ModalState.Success } .onFailure { _modalState.value = ModalState.Failure } } } catch (e: Exception) { _modalState.value = ModalState.Failure } } } private fun checkAvailability() { coroutineScope.launch { val eventInfo = createEventInfo() val busyEvents = checkBookingUseCase.busyEvents(eventInfo, room) val isBusy = busyEvents.isNotEmpty() _state.update { it.copy(isBusyEvent = isBusy) } checkEnableButton() } } private fun checkEnableButton() { val currentState = state.value val isEnable = !currentState.isInputError && !currentState.isBusyEvent && (currentState.event.isNotCreated() || currentState.duration != 30 || // Default duration currentState.selectOrganizer != currentState.event.organizer) _state.update { it.copy(enableUpdateButton = isEnable) } } private fun createEventInfo(): EventInfo { val currentState = state.value // For simplicity, we'll just use the existing event with updated organizer return currentState.event.copy( organizer = currentState.selectOrganizer, isLoading = false ) } } /** * State for the update event component */ data class UpdateEventState( val duration: Int = 30, val organizers: List<Organizer> = listOf(), val selectOrganizers: List<Organizer> = listOf(), val selectOrganizer: Organizer = Organizer.default, val expanded: Boolean = false, val event: EventInfo = EventInfo.emptyEvent, val inputText: String = "", val isInputError: Boolean = false, val isLoadDelete: Boolean = false, val isErrorDelete: Boolean = false, val showSelectDate: Boolean = false, val enableUpdateButton: Boolean = false, val isBusyEvent: Boolean = false ) { fun isCreatedEvent() = !event.isNotCreated() } /** * Modal state for the update event component */ sealed class ModalState { object Update : ModalState() object Success : ModalState() object Failure : ModalState() }

При миграции фичи бронирования переговорных Junie перенёс код и применил мои правила.

Например, он отказался от использования библиотеки MVI Kotlin и перенёс весь MVI-код напрямую в класс компонента. Это сэкономило время на рутинной работе.

Junie реализовал компонент на моковых данных, хотя я передал ему полностью рабочий код. Теоретически агент мог бы использовать его, но вместо этого выбрал упрощённый путь.

Исправлять и дорабатывать такие моменты всё равно пришлось вручную, и это еще раз подсвечивает нам, что Junie стоит воспринимать скорее как помощника, а не как полноценного разработчика.

Отдельной задачей стала миграция со старых java.util.Date и java.time API на kotlinx-datetime для поддержки мультиплатформы. Агент помог найти и заменить большинство вызовов, но не учёл некоторые нюансы: например, не всегда применял уже готовые экстеншены или, не учитывая текущую версию библиотеки kotlinx-datetime, использовал старое API.

migrate usecases from clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/useCase to kotlinx-datetime

Результат:

Как Junie от JetBrains помогла ускорить разработку внутреннего проекта

Junie полезен для ускорения рутинных задач, но без внимания со стороны разработчика он легко может завести в тупик.

В итоге я выработал следующий подход к работе с Junie:

  • разбивать задачи на маленькие шаги;
  • давать простые и точные промпты;
  • собирать большую фичу постепенно, контролируя результат на каждом этапе.

Такой метод оказался надёжнее, чем попытка отдать всё и сразу, приводившая к тому, что Junie упрощал реализацию и часто упускал важные детали.

Также я думаю, что восприятие качества работы Junie сильно зависит от профиля разработчика. Мой основной опыт — мобильная разработка, поэтому я вижу все недочёты агента именно в этой области. Возможно, если бы я был backend-разработчиком на Spring, мне бы тоже не понравилось, как Junie пишет серверный код.

Написание документации

Мы сгенерировали README.md с помощью простого промпта:

It must contains : - Overview - Features - Architecture - Getting Started - Installation - Project Structure: navigation structure for separated client/tablet, backend readme files - Development Tools - Code Style & Conventions - Contributing - Roadmap - Authors take into account existing scripts for convenient deployment of the project, scripts for checking leaks, script install.sh they must be performed at the very beginning of familiarization with the repository. The documentation should be short but clear. Detailed documentation on the backend and client/tablet should be in separate files inside the respective folders.

Безусловно, после генерации мы сделали многочисленные правки. Однако Junie способен взять на себя создание базовой структуры файла и добавление технической информации.

Аналогичным образом мы сгенерировали и другие файлы, например CONTRIBUTING.md, а затем внесли финальные корректировки вручную.

Gradle-скрипты и bash-скрипты

Мы также использовали Junie для автоматизации деплоя бэкенда на наши серверы.

Junie сгенерировал следующие gradle-таски deployDev и deployProd:

Здесь есть ещё несколько примеров, которые сгенерировал Junie. Они, конечно, предельно простые, но хорошо показывают, что Junie подходит для широкого спектра технических задач.

Плюсы и минусы

Плюсы

  • Разработка становится почти диалоговой. По моему опыту, процесс работы с Junie во многом сводится к тому, что я продумываю идею и архитектуру, а агент превращает это в код. Конечно, не всегда всё проходит идеально, но с каждым обновлением качество становится всё лучше.
  • Интеграция в IntelliJ IDEA. Junie встроен прямо в IDE, что делает его самым удобным AI-агентом для Kotlin-разработки. Больше никаких бесконечных переключений между idea или в браузер с копипастой из ChatGPT.
  • Понимание контекста. Junie отлично ориентируется в проекте, учитывает существующую архитектуру и корректно подстраивается под код-стайл.
  • Помощь с интеграцией внешних API. Особенно хорошо агент показал себя при работе с Google Calendar API и другими сторонними библиотеками.
  • Детальные планы действий. Перед выполнением задачи Junie строит чёткий пошаговый план, а затем следует ему. Можно наблюдать, какие команды он выполняет в терминале, что делает процесс прозрачным и обучающим.
  • Пошаговый прогресс. Приятно видеть, как Junie постепенно решает задачи. Это не только удобно, но и полезно для понимания процесса.
  • Тонкая настройка. Поддержка файлов .aignore и .guidelines.md позволяет настраивать поведение агента, ограничивать список доступных команд и даже включать brave mode, при котором Junie может выполнять действия без дополнительного подтверждения.
  • Отлично работает с Spring Boot. На серверной части Junie помогает особенно сильно: ускоряет настройку Gradle, генерацию boilerplate-кода и конфигурацию проекта.
  • Поддержка Community Edition и Android Studio. Изначально Junie был доступен только в Ultimate-версии IDE, но теперь он работает в Community Edition.

Минусы

  • Подписка на Junie. Это не столько минус, сколько фактор, который стоит учитывать: перед началом работы рекомендуется изучить тарифные планы и выбрать наиболее подходящий вариант для вас. Есть и пробный период, чтобы оценить инструмент в работе.
  • Скорость работы. Не то чтобы Junie был медленным, но иногда хочется быстрее, особенно на больших проектах.
  • Kotlin Multiplatform. В KMP-проектах агент ощущается менее полезным. Если в бэкенде соотношение кода Junie к моему примерно 70/30, то в клиентской части всё наоборот.
  • Зацикливание в сложных сценариях. Иногда Junie может бесконечно пытаться починить сборку: меняет код, собирает, откатывает изменения и снова пробует. Хотелось бы иметь возможность давать ему подсказки прямо во время выполнения задачи.
  • Недоработки в интерфейсе. Некоторые шорткаты в текстовом поле промпта на macOS не работают, функции .aignore и .guidelines.md спрятаны в неочевидное меню, а управление лицензией и настройками могло быть проще. Но это мелочи, которые пофиксят с апдейтами.

Выводы

Я не проводил строгих замеров производительности, поэтому дальше — только личные впечатления.

Ускорение разработки

При правильном использовании Junie действительно способен заметно ускорить работу. Грамотно составленные промпты и команды экономят массу времени на написание boilerplate-кода.

В среднем, по моим ощущениям, ускорение составляет около 30%, а в отдельных случаях — до 50%. Это не математические расчёты, а практический опыт. Например, на моих пет-проектах Junie позволяет реализовать то, на что раньше уходила неделя, всего за 2–3 вечера. Даже с ChatGPT на тех же задачах процесс был бы как минимум вдвое дольше.

Повторное использование паттернов

Если нужно добавить новую фичу по образцу уже существующей, Junie сокращает рутину почти до минимума. Это позволяет сосредоточиться на архитектуре и логике, а не тратить время на ручное написание однотипного кода.

Создание фич с нуля

Разработка новой функциональности с нуля требует большего контекста и более точных промптов, но даже в этом случае процесс ощутимо быстрее, чем писать всё вручную.

Документация без боли

С Junie написание документации стало лёгким и даже увлекательным процессом.

Обучение и ревью

В образовательном контексте Junie — отличный помощник. Он может быстро и понятно объяснить, что происходит в коде, указать на очевидные ошибки и подсветить возможные улучшения. Для junior-разработчиков это может стать чем-то вроде первого автоматического ревью кода.

Junie не волшебная палочка, но инструмент, который помогает разработчикам сосредоточиться на главном и быстрее достигать результата. В нашем случае он сыграл ключевую роль в ускорении разработки и миграции Effective Office.

Мы верим, что это Effective Office может быть полезен не только нам, но и другим компаниям. Если вы хотите:

  • упростить управление офисными ресурсами;
  • оптимизировать процессы бронирования переговорных;
  • интегрировать корпоративный календарь и автоматизировать рутину;
  • или просто внести вклад в open-source —

присоединяйтесь к нашему проекту!

Мы открыты для новых контрибьюторов и всегда рады единомышленникам. Вместе мы можем сделать инструмент, который поможет IT-командам по всему миру работать эффективнее!

12
Начать дискуссию