Spring Boot + Neo4j: Building Social Graphs with Modeling, Queries & Performance Optimization

This article demonstrates how to integrate Spring Boot with Neo4j to build a social relationship graph, covering graph data modeling, Cypher query patterns for friend recommendations and shortest paths, performance optimization techniques including indexing and query profiling, and a complete demo implementation for a social feed.

Xiaolin Talks Programming
Xiaolin Talks Programming
Xiaolin Talks Programming
Spring Boot + Neo4j: Building Social Graphs with Modeling, Queries & Performance Optimization

Why Graph Databases for Social Applications

Relational databases struggle with deep relationship queries: finding "friends of friends" requires multiple JOINs, and shortest-path queries need recursive CTEs or application-level loops. As data grows, SQL becomes painful to write and slow to execute.

Graph databases treat relationships as first-class citizens. Nodes represent entities (User, Post), edges represent relationships (FRIEND_OF, FOLLOWS, PUBLISHED, LIKES). Traversing edges is a pointer chase, not a JOIN, so query cost depends only on the explored subgraph, not the total graph size.

Graph Data Modeling for a Twitter-like Platform

Core Entities and Relationships

Two node types: User (userId, name, age, city) and Post (postId, content, createTime, likesCount). Four relationship types: (User)-[:FRIEND_OF]-(User) – bidirectional friendship (User)-[:FOLLOWS]->(User) – directional follow (follower → followee) (User)-[:PUBLISHED]->(Post) – user publishes post (User)-[:LIKES]->(Post) – user likes post

Modeling Best Practices

Don't over-create nodes. In Neo4j, edges can carry properties. For example, a LIKES edge can have a time property instead of creating a separate Like node. This differs from relational thinking where join tables hold extra columns.

Relationship properties illustrated:

FRIEND_OF: since (timestamp), level (intimacy)

FOLLOWS: createdAt LIKES: time These properties later enable recommendation ranking.

Relationship Direction Conventions

FOLLOWS direction: follower → followee. Querying followees uses OUTGOING; querying followers uses INCOMING. Document this convention to avoid inconsistencies.

FRIEND_OF is undirected. Store a single edge and query with UNDIRECTED (Spring Data Neo4j's Direction.UNDIRECTED). Creating two opposite edges risks "ghost relationships" if maintenance fails.

Spring Boot Integration with Neo4j

Dependencies and Configuration

Add Maven dependency:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-neo4j</artifactId>
</dependency>

Configuration (Spring Boot 3.x uses spring.neo4j.* prefix; 2.x used spring.data.neo4j.*):

spring:
  neo4j:
    uri: bolt://localhost:7687
    username: neo4j
    password: password
    database: social

Note: Protocol is bolt://, not http://.

Entity Definitions

Annotations resemble JPA but with graph semantics. Example User entity:

@Node("User")
public class User {
  @Id @GeneratedValue
  private Long id; // Neo4j internal ID

  @Property("userId") @Unique
  private String userId; // business ID

  @Property("name") private String name;
  @Property("age") private Integer age;
  @Property("city") @Index private String city;

  @Relationship(type = "FRIEND_OF", direction = Relationship.Direction.UNDIRECTED)
  private Set<User> friends;

  @Relationship(type = "FOLLOWS", direction = Relationship.Direction.OUTGOING)
  private Set<User> following;

  @Relationship(type = "FOLLOWS", direction = Relationship.Direction.INCOMING)
  private Set<User> followers;

  @Relationship(type = "PUBLISHED", direction = Relationship.Direction.OUTGOING)
  private Set<Post> posts;

  @Relationship(type = "LIKES", direction = Relationship.Direction.OUTGOING)
  private Set<Post> likedPosts;
}

Key detail: @Id maps to Neo4j's internal Long ID (auto-generated). Business ID userId is a regular @Property with @Unique constraint.

Relationship Entities with Properties

When relationships need properties, define a class annotated with @RelationshipProperties and use @TargetNode:

@RelationshipProperties
public class Friendship {
  @RelationshipId private Long id;
  @Property("since") private LocalDateTime since;
  @Property("level") private Integer level;
  @TargetNode private User friend;
}

Then in User:

@Relationship(type = "FRIEND_OF", direction = Relationship.Direction.UNDIRECTED) private List<Friendship> friendships;

Version note: Spring Data Neo4j 6.x replaced @RelationshipEntity, @StartNode, @EndNode with @RelationshipProperties and @TargetNode.

Repository Layer

Neo4jRepository<T, ID>

works like JpaRepository. Custom queries use @Query with Cypher:

public interface UserRepository extends Neo4jRepository<User, Long> {
  Optional<User> findByUserId(String userId);
  @Query("MATCH (u:User) WHERE u.city = $city RETURN u")
  List<User> findByCity(@Param("city") String city);
}

Save Semantics and Performance

save()

performs deep persistence: it compares the entire object graph with the database and applies incremental updates. Example:

User user = userRepository.findByUserId(userId).orElseThrow();
User target = userRepository.findByUserId(targetUserId).orElseThrow();
user.getFollowing().add(target);
userRepository.save(user); // creates FOLLOWS relationship

However, for single relationship additions, a direct Cypher MERGE is lighter and idempotent:

@Modifying
@Query("MATCH (a:User {userId: $userId}), (b:User {userId: $targetId}) MERGE (a)-[:FOLLOWS]->(b)")
void addFollowing(@Param("userId") String userId, @Param("targetId") String targetId);

Cypher Query Patterns for Social Features

Friend-of-Friend Recommendation

MATCH (me:User {userId: $userId})-[:FRIEND_OF]->(friend:User)-[:FRIEND_OF]->(candidate:User)
WHERE NOT (me)-[:FRIEND_OF]->(candidate) AND candidate <> me
RETURN candidate, count(*) AS mutualCount
ORDER BY mutualCount DESC
LIMIT 10

Extending to three degrees adds another hop in the MATCH pattern.

Common Follows Analysis

MATCH (u1:User {userId: $userId1})-[:FOLLOWS]->(common:User)<-[:FOLLOWS]-(u2:User {userId: $userId2})
RETURN common

Shortest Path Between Users

MATCH (start:User {userId: $startId}), (end:User {userId: $endId}),
      path = shortestPath((start)-[:FRIEND_OF|FOLLOWS*..6]-(end))
RETURN [n IN nodes(path) | n.userId] AS userIds, length(path) AS depth

Critical: *..6 limits max depth to 6; unbounded paths ( *) can traverse the entire graph. Multiple relationship types allowed in the pattern.

Feed Retrieval (Posts from Followees)

MATCH (me:User {userId: $userId})-[:FOLLOWS]->(followee:User)-[:PUBLISHED]->(post:Post)
WHERE NOT (me)-[:BLOCKED]-(followee)
RETURN post, followee.name AS author
ORDER BY post.createTime DESC
LIMIT $limit

The NOT (me)-[:BLOCKED]-(followee) filter excludes blocked users without a LEFT JOIN.

Performance Optimization

Why Graph Traversals Are Fast

Neo4j uses index-free adjacency : each node stores direct references to its adjacent edges. Traversal follows pointers, so cost is proportional to visited nodes, not total graph size. This makes deep relationship queries stable even as the graph grows.

However, graph databases are not universally faster. Pure aggregation queries (e.g., "count users per city") are OLAP workloads where relational databases excel.

Indexing Strategy

Traversals don't need indexes, but finding the start node does. Essential indexes:

Unique constraint on User.userId Index on User.name if searched by name

Index on Post.createTime for time-range queries

Index on User.city for location filters

Define via annotations ( @Unique, @Index) or explicit Cypher:

CREATE CONSTRAINT unique_user_id IF NOT EXISTS FOR (n:User) REQUIRE n.userId IS UNIQUE;
CREATE INDEX user_city_index IF NOT EXISTS FOR (n:User) ON (n.city);
CREATE INDEX post_createTime_index IF NOT EXISTS FOR (n:Post) ON (n.createTime);

Query Design Pitfalls

Avoid unbounded paths. Always specify an upper bound (e.g., *..6).

Use PROFILE. Prefix queries with PROFILE in Neo4j Browser to see row counts and DB hits per operator. Often reveals scans orders of magnitude larger than expected.

Application-Level Caching

Spring Cache can cache results like common-follows analysis:

@Cacheable(value = "commonFollowing", key = "#userId1 + ':' + #userId2")
public List<User> getCommonFollowing(String userId1, String userId2) {
  return userRepository.findCommonFollowing(userId1, userId2);
}

TTL-based expiration (e.g., 5 minutes) is simple; precise invalidation requires evicting on follow/unfollow events.

Bulk Write Performance

Don't call save() in a loop. Use UNWIND for batch relationship creation:

UNWIND $batch as row
MATCH (u:User {userId: row.userId})
MATCH (f:User {userId: row.followsUserId})
MERGE (u)-[:FOLLOWS]->(f)

For initial loads of millions of records, use neo4j-admin import (offline bulk loader), which is orders of magnitude faster than API writes.

Demo: Social Feed Endpoint

Repository

public interface PostRepository extends Neo4jRepository<Post, Long> {
  @Query("MATCH (me:User {userId: $userId})-[:FOLLOWS]->(followee:User)-[:PUBLISHED]->(post:Post) " +
         "WHERE post.createTime >= $since " +
         "RETURN post, followee.name AS author " +
         "ORDER BY post.createTime DESC LIMIT $limit")
  List<Map<String, Object>> findFeedByUser(
    @Param("userId") String userId,
    @Param("since") LocalDateTime since,
    @Param("limit") int limit);
}

Service

@Service
public class FeedService {
  private final PostRepository postRepository;
  private final UserRepository userRepository;

  @Transactional(readOnly = true)
  public FeedResult getFeed(String userId) {
    LocalDateTime since = LocalDateTime.now().minusDays(7);
    List<Map<String, Object>> feed = postRepository.findFeedByUser(userId, since, 50);
    List<Map<String, Object>> suggestions = userRepository.findFriendSuggestions(userId, 10);
    FeedResult result = new FeedResult();
    result.setFeed(feed);
    result.setSuggestedUsers(suggestions);
    return result;
  }
}

Controller

@RestController
@RequestMapping("/api/social")
public class SocialController {
  private final FeedService feedService;

  @GetMapping("/feed/{userId}")
  public FeedResult getFeed(@PathVariable String userId) {
    return feedService.getFeed(userId);
  }
}

Calling /api/social/feed/Alice returns posts from Alice's followees (Bob, Carol) ordered by time, plus friend suggestions (e.g., Dave with 2 common follows).

When Not to Use a Graph Database

Pure attribute aggregation (e.g., "count 30-year-old users in Beijing") – SQL GROUP BY is faster.

Highly regular, fixed-schema data (e.g., e-commerce orders) – relational model is simpler.

Billion-node scale – Neo4j Community Edition is single-node; distributed graph databases add operational complexity.

A pragmatic hybrid architecture: MySQL for core transactions, Neo4j for relationship-depth analysis, synchronized via message queues.

Spring Data Neo4j eliminates most boilerplate; developers familiar with JPA transition easily. Cypher is approachable – half a day of documentation and examples suffices. Best practice: start with a real query requirement and iterate.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Performance OptimizationGraph DatabaseData ModelingSpring BootNeo4jSocial GraphCypherSpring Data Neo4j
Xiaolin Talks Programming
Written by

Xiaolin Talks Programming

Focuses on sharing original technical insights. Senior architect at a top tech company with years of experience in technical architecture and management, and extensive interview experience. Offers one-on-one technical coaching, guiding you from beginner to architecture design to technical management. Follow for free learning resources. Free one-on-one interview coaching to help you land offers quickly.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.